feat: add starred message filters

Co-authored-by: Dan Rosenshain <danrosenshain@gmail.com>
This commit is contained in:
Dan 2026-05-05 11:44:57 +03:00 committed by GitHub
parent 9fff67cd3b
commit cd311e86c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 687 additions and 188 deletions

View File

@ -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)

View File

@ -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]`

View File

@ -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)
}

View File

@ -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)

View 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)
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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]`

View File

@ -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 {

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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) {

View File

@ -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 {

View File

@ -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 {

View File

@ -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) {

View File

@ -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,'') = ''"

View File

@ -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
View 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...)
}

View File

@ -67,6 +67,8 @@ type Message struct {
DirectPath string
LocalPath string
DownloadedAt time.Time
Starred bool
StarredAt time.Time
Snippet string
rowID int64
}

View File

@ -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 == "" {

View File

@ -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")