feat: add starred message filters
Co-authored-by: Dan Rosenshain <danrosenshain@gmail.com>
This commit is contained in:
parent
9fff67cd3b
commit
cd311e86c4
@ -11,6 +11,7 @@
|
||||
- CLI: add `presence typing` and `presence paused` commands for WhatsApp composing indicators. (#76 — thanks @redemerco)
|
||||
- Diagnostics: show linked JID and local store counts in `auth status` and `doctor`. (#149 — thanks @draix)
|
||||
- Messages: add `messages list --sender`, `--from-me`, `--from-them`, and `--asc` filters. (#153 — thanks @draix)
|
||||
- Messages: track WhatsApp starred state and add `messages starred` plus `--starred` filters for list/search. (#17 — thanks @dan-dr)
|
||||
- Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej)
|
||||
- Messages: add JSON export with `messages export --after` and `--before` filters.
|
||||
- Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm)
|
||||
|
||||
@ -21,7 +21,7 @@ Full docs site: <https://wacli.sh>.
|
||||
- [Overview](docs/overview.md): store model, global flags, common flow, command index.
|
||||
- [Auth](docs/auth.md): `auth`, `auth status`, `auth logout`.
|
||||
- [Sync](docs/sync.md): `sync --once`, `sync --follow`, refresh, media download.
|
||||
- [Messages](docs/messages.md): `messages list/search/show/context`.
|
||||
- [Messages](docs/messages.md): `messages list/search/starred/show/context`.
|
||||
- [Send](docs/send.md): `send text/file/react`, recipient resolution, replies.
|
||||
- [Media](docs/media.md): `media download`.
|
||||
- [Contacts](docs/contacts.md): `contacts search/show/refresh`, aliases, tags.
|
||||
@ -163,8 +163,9 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
|
||||
- `wacli auth status`
|
||||
- `wacli auth logout`
|
||||
- `wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-messages N] [--max-db-size SIZE] [--download-media] [--refresh-contacts] [--refresh-groups]`
|
||||
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded]`
|
||||
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded]`
|
||||
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded] [--starred]`
|
||||
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded] [--starred]`
|
||||
- `wacli messages starred [--chat JID] [--limit N] [--after DATE] [--before DATE] [--asc]`
|
||||
- `wacli messages export [--chat JID] [--limit N] [--after DATE] [--before DATE] [--output PATH]`
|
||||
- `wacli messages show --chat JID --id MSG_ID`
|
||||
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
|
||||
|
||||
@ -2,20 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func newMessagesCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -25,6 +18,7 @@ func newMessagesCmd(flags *rootFlags) *cobra.Command {
|
||||
}
|
||||
cmd.AddCommand(newMessagesListCmd(flags))
|
||||
cmd.AddCommand(newMessagesSearchCmd(flags))
|
||||
cmd.AddCommand(newMessagesStarredCmd(flags))
|
||||
cmd.AddCommand(newMessagesShowCmd(flags))
|
||||
cmd.AddCommand(newMessagesContextCmd(flags))
|
||||
cmd.AddCommand(newMessagesExportCmd(flags))
|
||||
@ -41,6 +35,7 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
|
||||
var fromThem bool
|
||||
var asc bool
|
||||
var forwarded bool
|
||||
var starred bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@ -100,6 +95,7 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
|
||||
FromMe: fromMeFilter,
|
||||
Asc: asc,
|
||||
Forwarded: forwarded,
|
||||
Starred: starred,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@ -126,6 +122,7 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&fromThem, "from-them", false, "only messages received (not sent by me)")
|
||||
cmd.Flags().BoolVar(&asc, "asc", false, "show oldest messages first (default: newest first)")
|
||||
cmd.Flags().BoolVar(&forwarded, "forwarded", false, "only forwarded messages")
|
||||
cmd.Flags().BoolVar(&starred, "starred", false, "only starred messages")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -138,6 +135,7 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
var hasMedia bool
|
||||
var msgType string
|
||||
var forwarded bool
|
||||
var starred bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search <query>",
|
||||
@ -185,6 +183,7 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
HasMedia: hasMedia,
|
||||
Type: msgType,
|
||||
Forwarded: forwarded,
|
||||
Starred: starred,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@ -216,134 +215,78 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&hasMedia, "has-media", false, "only messages with media")
|
||||
cmd.Flags().StringVar(&msgType, "type", "", "message type filter (text|image|video|audio|document)")
|
||||
cmd.Flags().BoolVar(&forwarded, "forwarded", false, "only forwarded messages")
|
||||
cmd.Flags().BoolVar(&starred, "starred", false, "only starred messages")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func messageChatJIDFilter(ctx context.Context, a *app.App, chat string) ([]string, error) {
|
||||
chat = strings.TrimSpace(chat)
|
||||
if chat == "" {
|
||||
return nil, nil
|
||||
}
|
||||
jid, err := wa.ParseUserOrJID(chat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jids := []types.JID{canonicalMessageFilterJID(jid)}
|
||||
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
if err := a.OpenWA(); err != nil {
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
client := a.WA()
|
||||
if client == nil {
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
switch jid.Server {
|
||||
case types.DefaultUserServer:
|
||||
jids = append(jids, canonicalMessageFilterJID(client.ResolvePNToLID(ctx, jid)))
|
||||
case types.HiddenUserServer:
|
||||
jids = append(jids, canonicalMessageFilterJID(client.ResolveLIDToPN(ctx, jid)))
|
||||
}
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
func newMessagesStarredCmd(flags *rootFlags) *cobra.Command {
|
||||
var chat string
|
||||
var limit int
|
||||
var afterStr string
|
||||
var beforeStr string
|
||||
var asc bool
|
||||
|
||||
func canonicalMessageFilterJID(jid types.JID) types.JID {
|
||||
if jid.Server == types.DefaultUserServer {
|
||||
return jid.ToNonAD()
|
||||
}
|
||||
return jid
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "starred",
|
||||
Short: "List starred messages",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
func jidStrings(jids []types.JID) []string {
|
||||
out := make([]string, 0, len(jids))
|
||||
seen := make(map[string]struct{}, len(jids))
|
||||
for _, jid := range jids {
|
||||
if jid.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
s := jid.String()
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
a, lk, err := newApp(ctx, flags, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
type lidSenderResolver interface {
|
||||
ResolveLIDToPN(context.Context, types.JID) types.JID
|
||||
}
|
||||
var after *time.Time
|
||||
var before *time.Time
|
||||
if afterStr != "" {
|
||||
t, err := parseTime(afterStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
after = &t
|
||||
}
|
||||
if beforeStr != "" {
|
||||
t, err := parseTime(beforeStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before = &t
|
||||
}
|
||||
|
||||
func resolveMessageSenderNames(ctx context.Context, a *app.App, msgs []store.Message) []store.Message {
|
||||
if len(msgs) == 0 || !messagesNeedSenderResolution(msgs) {
|
||||
return msgs
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
|
||||
return msgs
|
||||
}
|
||||
if err := a.OpenWA(); err != nil {
|
||||
return msgs
|
||||
}
|
||||
return resolveMessageSenderNamesWith(ctx, a.DB(), a.WA(), msgs)
|
||||
}
|
||||
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs, err := a.DB().ListStarredMessages(store.ListStarredMessagesParams{
|
||||
ChatJIDs: chatJIDs,
|
||||
Limit: limit,
|
||||
After: after,
|
||||
Before: before,
|
||||
Asc: asc,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs = resolveMessageSenderNames(ctx, a, msgs)
|
||||
|
||||
func messagesNeedSenderResolution(msgs []store.Message) bool {
|
||||
for _, msg := range msgs {
|
||||
if !msg.FromMe && strings.TrimSpace(msg.SenderName) == "" && strings.HasSuffix(strings.TrimSpace(msg.SenderJID), "@"+types.HiddenUserServer) {
|
||||
return true
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{
|
||||
"messages": msgs,
|
||||
"fts": a.DB().HasFTS(),
|
||||
})
|
||||
}
|
||||
return writeMessagesStarred(os.Stdout, msgs, fullTableOutput(flags.fullOutput))
|
||||
},
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveMessageSenderNamesWith(ctx context.Context, db *store.DB, resolver lidSenderResolver, msgs []store.Message) []store.Message {
|
||||
if resolver == nil {
|
||||
return msgs
|
||||
}
|
||||
cache := map[string]string{}
|
||||
for i := range msgs {
|
||||
if msgs[i].FromMe || strings.TrimSpace(msgs[i].SenderName) != "" {
|
||||
continue
|
||||
}
|
||||
sender := strings.TrimSpace(msgs[i].SenderJID)
|
||||
if sender == "" {
|
||||
continue
|
||||
}
|
||||
if name, ok := cache[sender]; ok {
|
||||
msgs[i].SenderName = name
|
||||
continue
|
||||
}
|
||||
name := resolvedSenderName(ctx, db, resolver, sender)
|
||||
cache[sender] = name
|
||||
msgs[i].SenderName = name
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func resolvedSenderName(ctx context.Context, db *store.DB, resolver lidSenderResolver, sender string) string {
|
||||
jid, err := types.ParseJID(sender)
|
||||
if err != nil || jid.Server != types.HiddenUserServer {
|
||||
return ""
|
||||
}
|
||||
pn := resolver.ResolveLIDToPN(ctx, jid)
|
||||
if pn.IsEmpty() || pn == jid {
|
||||
return ""
|
||||
}
|
||||
contact, err := db.GetContact(pn.String())
|
||||
if err == nil {
|
||||
if contact.Alias != "" {
|
||||
return contact.Alias
|
||||
}
|
||||
if contact.Name != "" {
|
||||
return contact.Name
|
||||
}
|
||||
if contact.Phone != "" {
|
||||
return contact.Phone
|
||||
}
|
||||
}
|
||||
return pn.String()
|
||||
cmd.Flags().StringVar(&chat, "chat", "", "filter by chat JID")
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "max number of messages to return")
|
||||
cmd.Flags().StringVar(&afterStr, "after", "", "only messages with stored star time after time (RFC3339 or YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages with stored star time before time (RFC3339 or YYYY-MM-DD)")
|
||||
cmd.Flags().BoolVar(&asc, "asc", false, "show oldest starred messages first (default: newest starred first)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newMessagesShowCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -515,43 +458,3 @@ func newMessagesExportCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.Flags().StringVar(&output, "output", "", "write JSON export to file instead of stdout")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func getMessageByChatFilter(db *store.DB, chatJIDs []string, id string) (store.Message, error) {
|
||||
var notFound error
|
||||
for _, chatJID := range chatJIDs {
|
||||
m, err := db.GetMessage(chatJID, id)
|
||||
if err == nil {
|
||||
return m, nil
|
||||
}
|
||||
if !isNoRows(err) {
|
||||
return store.Message{}, err
|
||||
}
|
||||
notFound = err
|
||||
}
|
||||
if notFound != nil {
|
||||
return store.Message{}, notFound
|
||||
}
|
||||
return store.Message{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func getMessageContextByChatFilter(db *store.DB, chatJIDs []string, id string, before, after int) ([]store.Message, error) {
|
||||
var notFound error
|
||||
for _, chatJID := range chatJIDs {
|
||||
msgs, err := db.MessageContext(chatJID, id, before, after)
|
||||
if err == nil {
|
||||
return msgs, nil
|
||||
}
|
||||
if !isNoRows(err) {
|
||||
return nil, err
|
||||
}
|
||||
notFound = err
|
||||
}
|
||||
if notFound != nil {
|
||||
return nil, notFound
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func isNoRows(err error) bool {
|
||||
return errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
@ -51,6 +51,26 @@ func writeMessagesSearch(dst io.Writer, msgs []store.Message, fullOutput bool) e
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func writeMessagesStarred(dst io.Writer, msgs []store.Message, fullOutput bool) error {
|
||||
w := newTableWriter(dst)
|
||||
fmt.Fprintln(w, "STARRED\tTIME\tCHAT\tFROM\tID\tTEXT")
|
||||
for _, m := range msgs {
|
||||
chatLabel := m.ChatName
|
||||
if chatLabel == "" {
|
||||
chatLabel = m.ChatJID
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
m.StarredAt.Local().Format("2006-01-02 15:04:05"),
|
||||
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
|
||||
tableCell(chatLabel, 24, fullOutput),
|
||||
tableCell(messageFrom(m), 18, fullOutput),
|
||||
tableCell(m.MsgID, 14, fullOutput),
|
||||
tableCell(messageText(m), 80, fullOutput),
|
||||
)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func writeMessageShow(dst io.Writer, m store.Message) error {
|
||||
fmt.Fprintf(dst, "Chat: %s\n", m.ChatJID)
|
||||
if m.ChatName != "" {
|
||||
@ -83,6 +103,12 @@ func writeMessageShow(dst io.Writer, m store.Message) error {
|
||||
fmt.Fprintf(dst, "Forwarding score: %d\n", m.ForwardingScore)
|
||||
}
|
||||
}
|
||||
if m.Starred {
|
||||
fmt.Fprintln(dst, "Starred: yes")
|
||||
if !m.StarredAt.IsZero() {
|
||||
fmt.Fprintf(dst, "Starred at: %s\n", m.StarredAt.Local().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(dst, "\n%s\n", messageText(m))
|
||||
if raw := messageRawText(m); raw != "" {
|
||||
fmt.Fprintf(dst, "\nRaw text:\n%s\n", raw)
|
||||
|
||||
182
cmd/wacli/messages_helpers.go
Normal file
182
cmd/wacli/messages_helpers.go
Normal file
@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func messageChatJIDFilter(ctx context.Context, a *app.App, chat string) ([]string, error) {
|
||||
chat = strings.TrimSpace(chat)
|
||||
if chat == "" {
|
||||
return nil, nil
|
||||
}
|
||||
jid, err := wa.ParseUserOrJID(chat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jids := []types.JID{canonicalMessageFilterJID(jid)}
|
||||
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
if err := a.OpenWA(); err != nil {
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
client := a.WA()
|
||||
if client == nil {
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
switch jid.Server {
|
||||
case types.DefaultUserServer:
|
||||
jids = append(jids, canonicalMessageFilterJID(client.ResolvePNToLID(ctx, jid)))
|
||||
case types.HiddenUserServer:
|
||||
jids = append(jids, canonicalMessageFilterJID(client.ResolveLIDToPN(ctx, jid)))
|
||||
}
|
||||
return jidStrings(jids), nil
|
||||
}
|
||||
|
||||
func canonicalMessageFilterJID(jid types.JID) types.JID {
|
||||
if jid.Server == types.DefaultUserServer {
|
||||
return jid.ToNonAD()
|
||||
}
|
||||
return jid
|
||||
}
|
||||
|
||||
func jidStrings(jids []types.JID) []string {
|
||||
out := make([]string, 0, len(jids))
|
||||
seen := make(map[string]struct{}, len(jids))
|
||||
for _, jid := range jids {
|
||||
if jid.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
s := jid.String()
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type lidSenderResolver interface {
|
||||
ResolveLIDToPN(context.Context, types.JID) types.JID
|
||||
}
|
||||
|
||||
func resolveMessageSenderNames(ctx context.Context, a *app.App, msgs []store.Message) []store.Message {
|
||||
if len(msgs) == 0 || !messagesNeedSenderResolution(msgs) {
|
||||
return msgs
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
|
||||
return msgs
|
||||
}
|
||||
if err := a.OpenWA(); err != nil {
|
||||
return msgs
|
||||
}
|
||||
return resolveMessageSenderNamesWith(ctx, a.DB(), a.WA(), msgs)
|
||||
}
|
||||
|
||||
func messagesNeedSenderResolution(msgs []store.Message) bool {
|
||||
for _, msg := range msgs {
|
||||
if !msg.FromMe && strings.TrimSpace(msg.SenderName) == "" && strings.HasSuffix(strings.TrimSpace(msg.SenderJID), "@"+types.HiddenUserServer) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveMessageSenderNamesWith(ctx context.Context, db *store.DB, resolver lidSenderResolver, msgs []store.Message) []store.Message {
|
||||
if resolver == nil {
|
||||
return msgs
|
||||
}
|
||||
cache := map[string]string{}
|
||||
for i := range msgs {
|
||||
if msgs[i].FromMe || strings.TrimSpace(msgs[i].SenderName) != "" {
|
||||
continue
|
||||
}
|
||||
sender := strings.TrimSpace(msgs[i].SenderJID)
|
||||
if sender == "" {
|
||||
continue
|
||||
}
|
||||
if name, ok := cache[sender]; ok {
|
||||
msgs[i].SenderName = name
|
||||
continue
|
||||
}
|
||||
name := resolvedSenderName(ctx, db, resolver, sender)
|
||||
cache[sender] = name
|
||||
msgs[i].SenderName = name
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func resolvedSenderName(ctx context.Context, db *store.DB, resolver lidSenderResolver, sender string) string {
|
||||
jid, err := types.ParseJID(sender)
|
||||
if err != nil || jid.Server != types.HiddenUserServer {
|
||||
return ""
|
||||
}
|
||||
pn := resolver.ResolveLIDToPN(ctx, jid)
|
||||
if pn.IsEmpty() || pn == jid {
|
||||
return ""
|
||||
}
|
||||
contact, err := db.GetContact(pn.String())
|
||||
if err == nil {
|
||||
if contact.Alias != "" {
|
||||
return contact.Alias
|
||||
}
|
||||
if contact.Name != "" {
|
||||
return contact.Name
|
||||
}
|
||||
if contact.Phone != "" {
|
||||
return contact.Phone
|
||||
}
|
||||
}
|
||||
return pn.String()
|
||||
}
|
||||
|
||||
func getMessageByChatFilter(db *store.DB, chatJIDs []string, id string) (store.Message, error) {
|
||||
var notFound error
|
||||
for _, chatJID := range chatJIDs {
|
||||
m, err := db.GetMessage(chatJID, id)
|
||||
if err == nil {
|
||||
return m, nil
|
||||
}
|
||||
if !isNoRows(err) {
|
||||
return store.Message{}, err
|
||||
}
|
||||
notFound = err
|
||||
}
|
||||
if notFound != nil {
|
||||
return store.Message{}, notFound
|
||||
}
|
||||
return store.Message{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func getMessageContextByChatFilter(db *store.DB, chatJIDs []string, id string, before, after int) ([]store.Message, error) {
|
||||
var notFound error
|
||||
for _, chatJID := range chatJIDs {
|
||||
msgs, err := db.MessageContext(chatJID, id, before, after)
|
||||
if err == nil {
|
||||
return msgs, nil
|
||||
}
|
||||
if !isNoRows(err) {
|
||||
return nil, err
|
||||
}
|
||||
notFound = err
|
||||
}
|
||||
if notFound != nil {
|
||||
return nil, notFound
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func isNoRows(err error) bool {
|
||||
return errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
@ -160,7 +160,7 @@ func TestWriteMessageShowPrefersDisplayTextAndMediaDetails(t *testing.T) {
|
||||
|
||||
func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
|
||||
cmd := newMessagesSearchCmd(&rootFlags{})
|
||||
for _, name := range []string{"has-media", "type", "forwarded"} {
|
||||
for _, name := range []string{"has-media", "type", "forwarded", "starred"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("expected --%s flag", name)
|
||||
}
|
||||
@ -170,10 +170,21 @@ func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesListCommandExposesForwardedFilter(t *testing.T) {
|
||||
func TestMessagesListCommandExposesMessageFilters(t *testing.T) {
|
||||
cmd := newMessagesListCmd(&rootFlags{})
|
||||
if cmd.Flags().Lookup("forwarded") == nil {
|
||||
t.Fatalf("expected --forwarded flag")
|
||||
for _, name := range []string{"forwarded", "starred"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("expected --%s flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesStarredCommandExposesFilters(t *testing.T) {
|
||||
cmd := newMessagesStarredCmd(&rootFlags{})
|
||||
for _, name := range []string{"chat", "limit", "after", "before", "asc"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("expected --%s flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,8 +7,9 @@ Read when: listing, searching, exporting, showing, or inspecting local message c
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded]
|
||||
wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded] [--limit N] [--after DATE] [--before DATE]
|
||||
wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded] [--starred]
|
||||
wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded] [--starred] [--limit N] [--after DATE] [--before DATE]
|
||||
wacli messages starred [--chat JID] [--limit N] [--after DATE] [--before DATE] [--asc]
|
||||
wacli messages export [--chat JID] [--limit N] [--after DATE] [--before DATE] [--output PATH]
|
||||
wacli messages show --chat JID --id MSG_ID
|
||||
wacli messages context --chat JID --id MSG_ID [--before N] [--after N]
|
||||
@ -19,8 +20,15 @@ wacli messages context --chat JID --id MSG_ID [--before N] [--after N]
|
||||
- Uses SQLite FTS5 when the binary was built with `-tags sqlite_fts5`.
|
||||
- Falls back to `LIKE` if FTS5 is not available.
|
||||
- `--type` accepts `text`, `image`, `video`, `audio`, or `document`.
|
||||
- `--starred` restricts list/search results to messages marked as starred by WhatsApp.
|
||||
- Time filters accept RFC3339 or `YYYY-MM-DD`.
|
||||
|
||||
## Starred
|
||||
|
||||
- `messages starred` lists starred messages ordered by star time when app-state events provide it; history-imported rows fall back to message time.
|
||||
- `--after` and `--before` on `messages starred` filter by that stored star time.
|
||||
- Starred state is imported from history sync and app-state star/unstar events.
|
||||
|
||||
## Export
|
||||
|
||||
- `messages export` writes a JSON export envelope with messages ordered oldest first.
|
||||
@ -37,7 +45,9 @@ When a phone-number chat JID maps to a stored `@lid` row, list/search/show/conte
|
||||
```bash
|
||||
wacli messages list --chat 1234567890@s.whatsapp.net --asc
|
||||
wacli messages list --from-me --limit 20
|
||||
wacli messages starred --limit 20
|
||||
wacli messages search "invoice" --has-media --type document
|
||||
wacli messages search "invoice" --starred
|
||||
wacli messages export --chat 1234567890@s.whatsapp.net --after 2024-01-01 --before 2024-02-01 --output messages.json
|
||||
wacli messages show --chat 1234567890@s.whatsapp.net --id ABC123
|
||||
wacli messages context --chat 1234567890@s.whatsapp.net --id ABC123 --before 3 --after 3
|
||||
|
||||
@ -171,8 +171,9 @@ WhatsApp Web history is best-effort. If you want to try fetching *older* message
|
||||
|
||||
### Messages
|
||||
|
||||
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--before TS] [--after TS]`
|
||||
- `wacli messages search <query> [--chat JID] [--from JID] [--limit N] [--before TS] [--after TS] [--type text|image|video|audio|document]`
|
||||
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--before TS] [--after TS] [--forwarded] [--starred]`
|
||||
- `wacli messages search <query> [--chat JID] [--from JID] [--limit N] [--before TS] [--after TS] [--type text|image|video|audio|document] [--forwarded] [--starred]`
|
||||
- `wacli messages starred [--chat JID] [--limit N] [--before TS] [--after TS] [--asc]`
|
||||
- `wacli messages export [--chat JID] [--limit N] [--before TS] [--after TS] [--output PATH]`
|
||||
- `wacli messages show --chat JID --id MSG_ID`
|
||||
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
|
||||
|
||||
@ -344,7 +344,7 @@ func (a *App) storeParsedMessage(ctx context.Context, pm wa.ParsedMessage) error
|
||||
|
||||
displayText := a.buildDisplayText(ctx, pm)
|
||||
|
||||
return a.db.UpsertMessage(store.UpsertMessageParams{
|
||||
if err := a.db.UpsertMessage(store.UpsertMessageParams{
|
||||
ChatJID: chatJID,
|
||||
ChatName: chatName,
|
||||
MsgID: pm.ID,
|
||||
@ -367,7 +367,20 @@ func (a *App) storeParsedMessage(ctx context.Context, pm wa.ParsedMessage) error
|
||||
FileSHA256: fileSha,
|
||||
FileEncSHA256: fileEncSha,
|
||||
FileLength: fileLen,
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if pm.StarredKnown {
|
||||
return a.db.SetStarred(store.SetStarredParams{
|
||||
ChatJID: chatJID,
|
||||
MsgID: pm.ID,
|
||||
SenderJID: senderJID,
|
||||
FromMe: pm.FromMe,
|
||||
Starred: pm.Starred,
|
||||
StarredAt: pm.Timestamp,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) buildDisplayText(ctx context.Context, pm wa.ParsedMessage) string {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/appstate"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
@ -58,6 +59,9 @@ func (a *App) addSyncEventHandler(ctx context.Context, opts SyncOptions, message
|
||||
case *events.HistorySync:
|
||||
lastEvent.Store(nowUTC().UnixNano())
|
||||
a.handleHistorySync(ctx, opts, v, messagesStored, lastEvent, enqueueMedia, limits)
|
||||
case *events.Star:
|
||||
lastEvent.Store(nowUTC().UnixNano())
|
||||
a.handleStarEvent(ctx, v)
|
||||
case *events.Connected:
|
||||
a.emitOrPrint("connected", nil, "\nConnected.\n")
|
||||
case *events.Disconnected:
|
||||
@ -72,6 +76,30 @@ func (a *App) addSyncEventHandler(ctx context.Context, opts SyncOptions, message
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleStarEvent(ctx context.Context, evt *events.Star) {
|
||||
if evt == nil || evt.ChatJID.IsEmpty() || strings.TrimSpace(evt.MessageID) == "" || evt.Action == nil {
|
||||
return
|
||||
}
|
||||
senderJID := ""
|
||||
if !evt.SenderJID.IsEmpty() {
|
||||
senderJID = canonicalJIDString(a.canonicalStoreJID(ctx, evt.SenderJID))
|
||||
}
|
||||
if err := a.db.SetStarred(store.SetStarredParams{
|
||||
ChatJID: canonicalJIDString(a.canonicalStoreJID(ctx, evt.ChatJID)),
|
||||
MsgID: evt.MessageID,
|
||||
SenderJID: senderJID,
|
||||
FromMe: evt.IsFromMe,
|
||||
Starred: evt.Action.GetStarred(),
|
||||
StarredAt: evt.Timestamp,
|
||||
}); err != nil {
|
||||
a.emitWarning(
|
||||
"starred_store_failed",
|
||||
fmt.Sprintf("warning: failed to store starred state for message %s: %v", evt.MessageID, err),
|
||||
map[string]any{"message_id": evt.MessageID, "error": err.Error()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleAppStateSyncError(ctx context.Context, evt *events.AppStateSyncError, recoveries *sync.Map) {
|
||||
if evt == nil || !errors.Is(evt.Error, appstate.ErrMismatchingLTHash) {
|
||||
return
|
||||
|
||||
@ -11,12 +11,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/appstate"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/proto/waCommon"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
"go.mau.fi/whatsmeow/proto/waHistorySync"
|
||||
"go.mau.fi/whatsmeow/proto/waSyncAction"
|
||||
"go.mau.fi/whatsmeow/proto/waWeb"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
"go.mau.fi/whatsmeow/types/events"
|
||||
@ -115,6 +117,57 @@ func TestAppStateNonLTHashErrorDoesNotRequestRecovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarEventStoresAndClearsStarredState(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
f := newFakeWA()
|
||||
a.wa = f
|
||||
|
||||
chat := types.JID{User: "123", Server: types.DefaultUserServer}
|
||||
if err := a.db.UpsertChat(chat.String(), "dm", "Alice", time.Now()); err != nil {
|
||||
t.Fatalf("UpsertChat: %v", err)
|
||||
}
|
||||
msgTime := time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||
if err := a.db.UpsertMessage(store.UpsertMessageParams{
|
||||
ChatJID: chat.String(),
|
||||
MsgID: "m-star",
|
||||
SenderJID: chat.String(),
|
||||
Timestamp: msgTime,
|
||||
Text: "save this",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMessage: %v", err)
|
||||
}
|
||||
|
||||
starredAt := msgTime.Add(time.Minute)
|
||||
a.handleStarEvent(context.Background(), &events.Star{
|
||||
ChatJID: chat,
|
||||
SenderJID: chat,
|
||||
MessageID: "m-star",
|
||||
Timestamp: starredAt,
|
||||
Action: &waSyncAction.StarAction{Starred: proto.Bool(true)},
|
||||
})
|
||||
msg, err := a.db.GetMessage(chat.String(), "m-star")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage starred: %v", err)
|
||||
}
|
||||
if !msg.Starred || !msg.StarredAt.Equal(starredAt) {
|
||||
t.Fatalf("unexpected starred state: %+v", msg)
|
||||
}
|
||||
|
||||
a.handleStarEvent(context.Background(), &events.Star{
|
||||
ChatJID: chat,
|
||||
MessageID: "m-star",
|
||||
Timestamp: starredAt.Add(time.Minute),
|
||||
Action: &waSyncAction.StarAction{Starred: proto.Bool(false)},
|
||||
})
|
||||
msg, err = a.db.GetMessage(chat.String(), "m-star")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage unstarred: %v", err)
|
||||
}
|
||||
if msg.Starred {
|
||||
t.Fatalf("expected unstarred message, got %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveSyncIgnoresHistorySyncProtocolMessage(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
f := newFakeWA()
|
||||
|
||||
@ -31,6 +31,17 @@ type UpsertMessageParams struct {
|
||||
FileLength uint64
|
||||
}
|
||||
|
||||
func messageSelectColumns(snippet string) string {
|
||||
return fmt.Sprintf(`m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.reaction_to_id,''), COALESCE(m.reaction_emoji,''), COALESCE(m.media_type,''), COALESCE(m.media_caption,''), COALESCE(m.filename,''), COALESCE(m.mime_type,''), COALESCE(m.direct_path,''), COALESCE(m.local_path,''), COALESCE(m.downloaded_at,0), CASE WHEN s.msg_id IS NULL THEN 0 ELSE 1 END, COALESCE(s.starred_at,0), %s`, snippetSQL(snippet))
|
||||
}
|
||||
|
||||
func snippetSQL(snippet string) string {
|
||||
if strings.TrimSpace(snippet) == "" {
|
||||
return "''"
|
||||
}
|
||||
return snippet
|
||||
}
|
||||
|
||||
func (d *DB) UpsertMessage(p UpsertMessageParams) error {
|
||||
_, err := d.sql.Exec(`
|
||||
INSERT INTO messages(
|
||||
@ -78,6 +89,7 @@ type ListMessagesParams struct {
|
||||
FromMe *bool
|
||||
Asc bool
|
||||
Forwarded bool
|
||||
Starred bool
|
||||
}
|
||||
|
||||
func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
|
||||
@ -85,9 +97,10 @@ func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
|
||||
p.Limit = 50
|
||||
}
|
||||
query := `
|
||||
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.reaction_to_id,''), COALESCE(m.reaction_emoji,''), COALESCE(m.media_type,''), COALESCE(m.media_caption,''), COALESCE(m.filename,''), COALESCE(m.mime_type,''), COALESCE(m.direct_path,''), COALESCE(m.local_path,''), COALESCE(m.downloaded_at,0), ''
|
||||
SELECT ` + messageSelectColumns("") + `
|
||||
FROM messages m
|
||||
LEFT JOIN chats c ON c.jid = m.chat_jid
|
||||
LEFT JOIN starred s ON s.chat_jid = m.chat_jid AND s.msg_id = m.msg_id
|
||||
WHERE 1=1`
|
||||
var args []interface{}
|
||||
query, args = appendStringFilter(query, args, "m.chat_jid", p.ChatJID, p.ChatJIDs)
|
||||
@ -110,6 +123,9 @@ func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
|
||||
if p.Forwarded {
|
||||
query += " AND m.is_forwarded = 1"
|
||||
}
|
||||
if p.Starred {
|
||||
query += " AND s.msg_id IS NOT NULL"
|
||||
}
|
||||
if p.Asc {
|
||||
query += " ORDER BY m.ts ASC, m.rowid ASC LIMIT ?"
|
||||
} else {
|
||||
@ -156,9 +172,10 @@ func uniqueNonEmptyStrings(values []string) []string {
|
||||
|
||||
func (d *DB) GetMessage(chatJID, msgID string) (Message, error) {
|
||||
row := d.sql.QueryRow(`
|
||||
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.reaction_to_id,''), COALESCE(m.reaction_emoji,''), COALESCE(m.media_type,''), COALESCE(m.media_caption,''), COALESCE(m.filename,''), COALESCE(m.mime_type,''), COALESCE(m.direct_path,''), COALESCE(m.local_path,''), COALESCE(m.downloaded_at,0), ''
|
||||
SELECT `+messageSelectColumns("")+`
|
||||
FROM messages m
|
||||
LEFT JOIN chats c ON c.jid = m.chat_jid
|
||||
LEFT JOIN starred s ON s.chat_jid = m.chat_jid AND s.msg_id = m.msg_id
|
||||
WHERE m.chat_jid = ? AND m.msg_id = ?
|
||||
`, chatJID, msgID)
|
||||
var m Message
|
||||
@ -167,7 +184,9 @@ func (d *DB) GetMessage(chatJID, msgID string) (Message, error) {
|
||||
var forwarded int
|
||||
var forwardingScore int64
|
||||
var downloadedAt int64
|
||||
if err := row.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &m.SenderName, &ts, &fromMe, &m.Text, &m.DisplayText, &forwarded, &forwardingScore, &m.ReactionToID, &m.ReactionEmoji, &m.MediaType, &m.MediaCaption, &m.Filename, &m.MimeType, &m.DirectPath, &m.LocalPath, &downloadedAt, &m.Snippet); err != nil {
|
||||
var starred int
|
||||
var starredAt int64
|
||||
if err := row.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &m.SenderName, &ts, &fromMe, &m.Text, &m.DisplayText, &forwarded, &forwardingScore, &m.ReactionToID, &m.ReactionEmoji, &m.MediaType, &m.MediaCaption, &m.Filename, &m.MimeType, &m.DirectPath, &m.LocalPath, &downloadedAt, &starred, &starredAt, &m.Snippet); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
m.Timestamp = fromUnix(ts)
|
||||
@ -175,6 +194,8 @@ func (d *DB) GetMessage(chatJID, msgID string) (Message, error) {
|
||||
m.IsForwarded = forwarded != 0
|
||||
m.ForwardingScore = uint32(forwardingScore)
|
||||
m.DownloadedAt = fromUnix(downloadedAt)
|
||||
m.Starred = starred != 0
|
||||
m.StarredAt = fromUnix(starredAt)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@ -223,9 +244,10 @@ func (d *DB) MessageContext(chatJID, msgID string, before, after int) ([]Message
|
||||
}
|
||||
|
||||
beforeRows, err := d.scanMessages(`
|
||||
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.reaction_to_id,''), COALESCE(m.reaction_emoji,''), COALESCE(m.media_type,''), COALESCE(m.media_caption,''), COALESCE(m.filename,''), COALESCE(m.mime_type,''), COALESCE(m.direct_path,''), COALESCE(m.local_path,''), COALESCE(m.downloaded_at,0), ''
|
||||
SELECT `+messageSelectColumns("")+`
|
||||
FROM messages m
|
||||
LEFT JOIN chats c ON c.jid = m.chat_jid
|
||||
LEFT JOIN starred s ON s.chat_jid = m.chat_jid AND s.msg_id = m.msg_id
|
||||
WHERE m.chat_jid = ? AND (m.ts < ? OR (m.ts = ? AND m.rowid < ?))
|
||||
ORDER BY m.ts DESC, m.rowid DESC
|
||||
LIMIT ?
|
||||
@ -235,9 +257,10 @@ func (d *DB) MessageContext(chatJID, msgID string, before, after int) ([]Message
|
||||
}
|
||||
|
||||
afterRows, err := d.scanMessages(`
|
||||
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.reaction_to_id,''), COALESCE(m.reaction_emoji,''), COALESCE(m.media_type,''), COALESCE(m.media_caption,''), COALESCE(m.filename,''), COALESCE(m.mime_type,''), COALESCE(m.direct_path,''), COALESCE(m.local_path,''), COALESCE(m.downloaded_at,0), ''
|
||||
SELECT `+messageSelectColumns("")+`
|
||||
FROM messages m
|
||||
LEFT JOIN chats c ON c.jid = m.chat_jid
|
||||
LEFT JOIN starred s ON s.chat_jid = m.chat_jid AND s.msg_id = m.msg_id
|
||||
WHERE m.chat_jid = ? AND (m.ts > ? OR (m.ts = ? AND m.rowid > ?))
|
||||
ORDER BY m.ts ASC, m.rowid ASC
|
||||
LIMIT ?
|
||||
@ -273,7 +296,9 @@ func (d *DB) scanMessages(query string, args ...interface{}) ([]Message, error)
|
||||
var forwarded int
|
||||
var forwardingScore int64
|
||||
var downloadedAt int64
|
||||
if err := rows.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &m.SenderName, &ts, &fromMe, &m.Text, &m.DisplayText, &forwarded, &forwardingScore, &m.ReactionToID, &m.ReactionEmoji, &m.MediaType, &m.MediaCaption, &m.Filename, &m.MimeType, &m.DirectPath, &m.LocalPath, &downloadedAt, &m.Snippet); err != nil {
|
||||
var starred int
|
||||
var starredAt int64
|
||||
if err := rows.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &m.SenderName, &ts, &fromMe, &m.Text, &m.DisplayText, &forwarded, &forwardingScore, &m.ReactionToID, &m.ReactionEmoji, &m.MediaType, &m.MediaCaption, &m.Filename, &m.MimeType, &m.DirectPath, &m.LocalPath, &downloadedAt, &starred, &starredAt, &m.Snippet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Timestamp = fromUnix(ts)
|
||||
@ -281,6 +306,8 @@ func (d *DB) scanMessages(query string, args ...interface{}) ([]Message, error)
|
||||
m.IsForwarded = forwarded != 0
|
||||
m.ForwardingScore = uint32(forwardingScore)
|
||||
m.DownloadedAt = fromUnix(downloadedAt)
|
||||
m.Starred = starred != 0
|
||||
m.StarredAt = fromUnix(starredAt)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@ -89,6 +89,17 @@ func TestListMessagesFiltersAndOrdering(t *testing.T) {
|
||||
t.Fatalf("UpsertMessage %s: %v", row.MsgID, err)
|
||||
}
|
||||
}
|
||||
starredAt := base.Add(4 * time.Second)
|
||||
if err := db.SetStarred(SetStarredParams{
|
||||
ChatJID: chat,
|
||||
MsgID: "new-from-me",
|
||||
SenderJID: "me@s.whatsapp.net",
|
||||
FromMe: true,
|
||||
Starred: true,
|
||||
StarredAt: starredAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStarred: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := db.ListMessages(ListMessagesParams{ChatJID: chat, Limit: 10})
|
||||
if err != nil {
|
||||
@ -136,6 +147,17 @@ func TestListMessagesFiltersAndOrdering(t *testing.T) {
|
||||
if msgs[0].ForwardingScore != 2 {
|
||||
t.Fatalf("ForwardingScore = %d, want 2", msgs[0].ForwardingScore)
|
||||
}
|
||||
|
||||
msgs, err = db.ListMessages(ListMessagesParams{ChatJID: chat, Limit: 10, Starred: true})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages starred: %v", err)
|
||||
}
|
||||
if got := messageIDs(msgs); got != "new-from-me" {
|
||||
t.Fatalf("starred filter = %s", got)
|
||||
}
|
||||
if !msgs[0].Starred || !msgs[0].StarredAt.Equal(starredAt) {
|
||||
t.Fatalf("unexpected starred metadata: %+v", msgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMessagesFiltersMultipleChatJIDs(t *testing.T) {
|
||||
@ -203,6 +225,16 @@ func TestGetMessageReturnsRichDetails(t *testing.T) {
|
||||
if err := db.MarkMediaDownloaded(chat, "mid", "/tmp/pic.jpg", downloadedAt); err != nil {
|
||||
t.Fatalf("MarkMediaDownloaded: %v", err)
|
||||
}
|
||||
starredAt := base.Add(2 * time.Second)
|
||||
if err := db.SetStarred(SetStarredParams{
|
||||
ChatJID: chat,
|
||||
MsgID: "mid",
|
||||
SenderJID: chat,
|
||||
Starred: true,
|
||||
StarredAt: starredAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStarred: %v", err)
|
||||
}
|
||||
|
||||
msg, err := db.GetMessage(chat, "mid")
|
||||
if err != nil {
|
||||
@ -220,6 +252,51 @@ func TestGetMessageReturnsRichDetails(t *testing.T) {
|
||||
if msg.LocalPath != "/tmp/pic.jpg" || !msg.DownloadedAt.Equal(downloadedAt) {
|
||||
t.Fatalf("unexpected download fields: %+v", msg)
|
||||
}
|
||||
if !msg.Starred || !msg.StarredAt.Equal(starredAt) {
|
||||
t.Fatalf("unexpected starred fields: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStarredMessagesOrdersByStarredTime(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
chat := "starred@s.whatsapp.net"
|
||||
base := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
if err := db.UpsertChat(chat, "dm", "Starred", base); err != nil {
|
||||
t.Fatalf("UpsertChat: %v", err)
|
||||
}
|
||||
for _, row := range []UpsertMessageParams{
|
||||
{ChatJID: chat, MsgID: "m1", SenderJID: chat, Timestamp: base, Text: "first"},
|
||||
{ChatJID: chat, MsgID: "m2", SenderJID: chat, Timestamp: base.Add(time.Second), Text: "second"},
|
||||
} {
|
||||
if err := db.UpsertMessage(row); err != nil {
|
||||
t.Fatalf("UpsertMessage %s: %v", row.MsgID, err)
|
||||
}
|
||||
}
|
||||
if err := db.SetStarred(SetStarredParams{ChatJID: chat, MsgID: "m1", Starred: true, StarredAt: base.Add(10 * time.Second)}); err != nil {
|
||||
t.Fatalf("SetStarred m1: %v", err)
|
||||
}
|
||||
if err := db.SetStarred(SetStarredParams{ChatJID: chat, MsgID: "m2", Starred: true, StarredAt: base.Add(5 * time.Second)}); err != nil {
|
||||
t.Fatalf("SetStarred m2: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := db.ListStarredMessages(ListStarredMessagesParams{ChatJID: chat, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListStarredMessages: %v", err)
|
||||
}
|
||||
if got := messageIDs(msgs); got != "m1,m2" {
|
||||
t.Fatalf("starred order = %s", got)
|
||||
}
|
||||
|
||||
if err := db.SetStarred(SetStarredParams{ChatJID: chat, MsgID: "m1", Starred: false}); err != nil {
|
||||
t.Fatalf("unstar m1: %v", err)
|
||||
}
|
||||
msgs, err = db.ListStarredMessages(ListStarredMessagesParams{ChatJID: chat, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListStarredMessages after unstar: %v", err)
|
||||
}
|
||||
if got := messageIDs(msgs); got != "m2" {
|
||||
t.Fatalf("starred after unstar = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMessagesStableSameTimestampOrder(t *testing.T) {
|
||||
|
||||
@ -20,6 +20,7 @@ var schemaMigrations = []migration{
|
||||
{version: 4, name: "groups left_at column", up: migrateGroupsLeftAt},
|
||||
{version: 5, name: "messages forwarded columns", up: migrateMessagesForwardedColumns},
|
||||
{version: 6, name: "messages reaction columns", up: migrateMessagesReactionColumns},
|
||||
{version: 7, name: "starred messages", up: migrateStarredMessages},
|
||||
}
|
||||
|
||||
func (d *DB) ensureSchema() error {
|
||||
@ -138,6 +139,23 @@ func migrateMessagesReactionColumns(d *DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateStarredMessages(d *DB) error {
|
||||
if _, err := d.sql.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS starred (
|
||||
chat_jid TEXT NOT NULL,
|
||||
msg_id TEXT NOT NULL,
|
||||
sender_jid TEXT,
|
||||
from_me INTEGER NOT NULL DEFAULT 0,
|
||||
starred_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (chat_jid, msg_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_starred_starred_at ON starred(starred_at);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create starred table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateMessagesFTS(d *DB) error {
|
||||
ftsExists, err := d.tableExists("messages_fts")
|
||||
if err != nil {
|
||||
|
||||
@ -84,6 +84,17 @@ const coreSchemaSQL = `
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_ts ON messages(chat_jid, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS starred (
|
||||
chat_jid TEXT NOT NULL,
|
||||
msg_id TEXT NOT NULL,
|
||||
sender_jid TEXT,
|
||||
from_me INTEGER NOT NULL DEFAULT 0,
|
||||
starred_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (chat_jid, msg_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_starred_starred_at ON starred(starred_at);
|
||||
`
|
||||
|
||||
func migrateCoreSchema(d *DB) error {
|
||||
|
||||
@ -37,6 +37,16 @@ func TestOpenCreatesExpectedSchema(t *testing.T) {
|
||||
t.Fatalf("expected messages column %q to exist", want)
|
||||
}
|
||||
}
|
||||
|
||||
starredCols, err := tableColumns(db.sql, "starred")
|
||||
if err != nil {
|
||||
t.Fatalf("starred tableColumns: %v", err)
|
||||
}
|
||||
for _, want := range []string{"chat_jid", "msg_id", "sender_jid", "from_me", "starred_at"} {
|
||||
if !starredCols[want] {
|
||||
t.Fatalf("expected starred column %q to exist", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableColumns(db *sql.DB, table string) (map[string]bool, error) {
|
||||
|
||||
@ -17,6 +17,7 @@ type SearchMessagesParams struct {
|
||||
HasMedia bool
|
||||
Type string
|
||||
Forwarded bool
|
||||
Starred bool
|
||||
}
|
||||
|
||||
func (d *DB) SearchMessages(p SearchMessagesParams) ([]Message, error) {
|
||||
@ -55,9 +56,10 @@ func likeContains(s string) string {
|
||||
|
||||
func (d *DB) searchLIKE(p SearchMessagesParams) ([]Message, error) {
|
||||
query := `
|
||||
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.reaction_to_id,''), COALESCE(m.reaction_emoji,''), COALESCE(m.media_type,''), COALESCE(m.media_caption,''), COALESCE(m.filename,''), COALESCE(m.mime_type,''), COALESCE(m.direct_path,''), COALESCE(m.local_path,''), COALESCE(m.downloaded_at,0), ''
|
||||
SELECT ` + messageSelectColumns("") + `
|
||||
FROM messages m
|
||||
LEFT JOIN chats c ON c.jid = m.chat_jid
|
||||
LEFT JOIN starred s ON s.chat_jid = m.chat_jid AND s.msg_id = m.msg_id
|
||||
WHERE (LOWER(m.text) LIKE LOWER(?) ESCAPE '\' OR LOWER(m.display_text) LIKE LOWER(?) ESCAPE '\' OR LOWER(m.media_caption) LIKE LOWER(?) ESCAPE '\' OR LOWER(m.filename) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(m.chat_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(m.sender_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.name,'')) LIKE LOWER(?) ESCAPE '\')`
|
||||
// Escape wildcards before wrapping in % so user input is literal (#56).
|
||||
needle := likeContains(p.Query)
|
||||
@ -88,11 +90,11 @@ func sanitizeFTSQuery(q string) string {
|
||||
|
||||
func (d *DB) searchFTS(p SearchMessagesParams) ([]Message, error) {
|
||||
query := `
|
||||
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.reaction_to_id,''), COALESCE(m.reaction_emoji,''), COALESCE(m.media_type,''), COALESCE(m.media_caption,''), COALESCE(m.filename,''), COALESCE(m.mime_type,''), COALESCE(m.direct_path,''), COALESCE(m.local_path,''), COALESCE(m.downloaded_at,0),
|
||||
snippet(messages_fts, 0, '[', ']', '…', 12)
|
||||
SELECT ` + messageSelectColumns("snippet(messages_fts, 0, '[', ']', '…', 12)") + `
|
||||
FROM messages_fts
|
||||
JOIN messages m ON messages_fts.rowid = m.rowid
|
||||
LEFT JOIN chats c ON c.jid = m.chat_jid
|
||||
LEFT JOIN starred s ON s.chat_jid = m.chat_jid AND s.msg_id = m.msg_id
|
||||
WHERE messages_fts MATCH ?`
|
||||
// Sanitize to prevent FTS5 query-syntax injection (#57).
|
||||
// Each token is individually quoted so multi-word queries still work
|
||||
@ -124,6 +126,9 @@ func applyMessageFilters(query string, args []interface{}, p SearchMessagesParam
|
||||
if p.Forwarded {
|
||||
query += " AND m.is_forwarded = 1"
|
||||
}
|
||||
if p.Starred {
|
||||
query += " AND s.msg_id IS NOT NULL"
|
||||
}
|
||||
if msgType := normalizedMessageType(p.Type); msgType != "" {
|
||||
if msgType == "text" {
|
||||
query += " AND COALESCE(m.media_type,'') = ''"
|
||||
|
||||
@ -66,6 +66,15 @@ func TestSearchMessagesFiltersByMediaAndType(t *testing.T) {
|
||||
t.Fatalf("UpsertMessage %s: %v", row.MsgID, err)
|
||||
}
|
||||
}
|
||||
if err := db.SetStarred(SetStarredParams{
|
||||
ChatJID: chat,
|
||||
MsgID: "image-1",
|
||||
SenderJID: chat,
|
||||
Starred: true,
|
||||
StarredAt: base.Add(4 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStarred: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@ -102,6 +111,11 @@ func TestSearchMessagesFiltersByMediaAndType(t *testing.T) {
|
||||
p: SearchMessagesParams{Query: "forwarded", Limit: 10, Forwarded: true},
|
||||
want: "forwarded-1",
|
||||
},
|
||||
{
|
||||
name: "starred",
|
||||
p: SearchMessagesParams{Query: "report", Limit: 10, Starred: true},
|
||||
want: "image-1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
82
internal/store/starred.go
Normal file
82
internal/store/starred.go
Normal file
@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SetStarredParams struct {
|
||||
ChatJID string
|
||||
MsgID string
|
||||
SenderJID string
|
||||
FromMe bool
|
||||
Starred bool
|
||||
StarredAt time.Time
|
||||
}
|
||||
|
||||
type ListStarredMessagesParams struct {
|
||||
ChatJID string
|
||||
ChatJIDs []string
|
||||
Limit int
|
||||
Before *time.Time
|
||||
After *time.Time
|
||||
Asc bool
|
||||
}
|
||||
|
||||
func (d *DB) SetStarred(p SetStarredParams) error {
|
||||
chatJID := strings.TrimSpace(p.ChatJID)
|
||||
msgID := strings.TrimSpace(p.MsgID)
|
||||
if chatJID == "" {
|
||||
return fmt.Errorf("chat JID is required")
|
||||
}
|
||||
if msgID == "" {
|
||||
return fmt.Errorf("message ID is required")
|
||||
}
|
||||
if !p.Starred {
|
||||
_, err := d.sql.Exec(`DELETE FROM starred WHERE chat_jid = ? AND msg_id = ?`, chatJID, msgID)
|
||||
return err
|
||||
}
|
||||
starredAt := p.StarredAt
|
||||
if starredAt.IsZero() {
|
||||
starredAt = nowUTC()
|
||||
}
|
||||
_, err := d.sql.Exec(`
|
||||
INSERT INTO starred(chat_jid, msg_id, sender_jid, from_me, starred_at)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chat_jid, msg_id) DO UPDATE SET
|
||||
sender_jid=COALESCE(NULLIF(excluded.sender_jid,''), starred.sender_jid),
|
||||
from_me=excluded.from_me,
|
||||
starred_at=excluded.starred_at
|
||||
`, chatJID, msgID, nullIfEmpty(p.SenderJID), boolToInt(p.FromMe), unix(starredAt))
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ListStarredMessages(p ListStarredMessagesParams) ([]Message, error) {
|
||||
if p.Limit <= 0 {
|
||||
p.Limit = 50
|
||||
}
|
||||
query := `
|
||||
SELECT ` + messageSelectColumns("") + `
|
||||
FROM messages m
|
||||
LEFT JOIN chats c ON c.jid = m.chat_jid
|
||||
JOIN starred s ON s.chat_jid = m.chat_jid AND s.msg_id = m.msg_id
|
||||
WHERE 1=1`
|
||||
var args []interface{}
|
||||
query, args = appendStringFilter(query, args, "m.chat_jid", p.ChatJID, p.ChatJIDs)
|
||||
if p.After != nil {
|
||||
query += " AND s.starred_at > ?"
|
||||
args = append(args, unix(*p.After))
|
||||
}
|
||||
if p.Before != nil {
|
||||
query += " AND s.starred_at < ?"
|
||||
args = append(args, unix(*p.Before))
|
||||
}
|
||||
if p.Asc {
|
||||
query += " ORDER BY s.starred_at ASC, m.rowid ASC LIMIT ?"
|
||||
} else {
|
||||
query += " ORDER BY s.starred_at DESC, m.rowid DESC LIMIT ?"
|
||||
}
|
||||
args = append(args, p.Limit)
|
||||
return d.scanMessages(query, args...)
|
||||
}
|
||||
@ -67,6 +67,8 @@ type Message struct {
|
||||
DirectPath string
|
||||
LocalPath string
|
||||
DownloadedAt time.Time
|
||||
Starred bool
|
||||
StarredAt time.Time
|
||||
Snippet string
|
||||
rowID int64
|
||||
}
|
||||
|
||||
@ -36,6 +36,8 @@ type ParsedMessage struct {
|
||||
ReactionEmoji string
|
||||
IsForwarded bool
|
||||
ForwardingScore uint32
|
||||
StarredKnown bool
|
||||
Starred bool
|
||||
}
|
||||
|
||||
func ParseLiveMessage(evt *events.Message) ParsedMessage {
|
||||
@ -66,6 +68,10 @@ func ParseHistoryMessage(chatJID string, hist *waProto.WebMessageInfo) ParsedMes
|
||||
Timestamp: time.Unix(int64(hist.GetMessageTimestamp()), 0).UTC(),
|
||||
FromMe: hist.GetKey().GetFromMe(),
|
||||
}
|
||||
if hist.Starred != nil {
|
||||
pm.StarredKnown = true
|
||||
pm.Starred = hist.GetStarred()
|
||||
}
|
||||
|
||||
sender := strings.TrimSpace(hist.GetParticipant())
|
||||
if sender == "" {
|
||||
|
||||
@ -71,6 +71,24 @@ func TestParseHistoryMessageKeyParticipantStillWorks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHistoryMessageStarred(t *testing.T) {
|
||||
starred := true
|
||||
h := &waProto.WebMessageInfo{
|
||||
Key: &waProto.MessageKey{
|
||||
ID: proto.String("starred-msg"),
|
||||
FromMe: proto.Bool(false),
|
||||
},
|
||||
MessageTimestamp: proto.Uint64(uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).Unix())),
|
||||
Message: &waProto.Message{Conversation: proto.String("saved")},
|
||||
Starred: &starred,
|
||||
}
|
||||
|
||||
pm := ParseHistoryMessage("123@s.whatsapp.net", h)
|
||||
if !pm.StarredKnown || !pm.Starred {
|
||||
t.Fatalf("expected starred state, got %+v", pm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLiveMessageImageClonesBytes(t *testing.T) {
|
||||
chat, _ := types.ParseJID("123@s.whatsapp.net")
|
||||
sender, _ := types.ParseJID("sender@s.whatsapp.net")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user