fix: validate message search media filters (#128)

Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
Co-authored-by: Mansehej Singh <mansehej@gmail.com>
This commit is contained in:
Luke 2026-04-21 14:43:41 +10:00 committed by GitHub
parent aa00e0a58e
commit 54d44b34fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 195 additions and 19 deletions

View File

@ -7,6 +7,7 @@
- CLI: add `--full` to disable table truncation; piped output now keeps full message IDs. (#13 — thanks @rickhallett)
- CLI: add `presence typing` and `presence paused` commands for WhatsApp composing indicators. (#76 — thanks @redemerco)
- Messages: add `messages list --sender`, `--from-me`, `--from-them`, and `--asc` filters. (#153 — thanks @draix)
- Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej)
- Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm)
### Security

View File

@ -107,7 +107,7 @@ pnpm wacli presence paused --to 1234567890
- `wacli auth logout`
- `wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--download-media] [--refresh-contacts] [--refresh-groups]`
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE]`
- `wacli messages search <query> [--chat JID] [--from JID] [--type image|video|audio|document]`
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document]`
- `wacli messages show --chat JID --id MSG_ID`
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
- `wacli send text --to PHONE_OR_JID --message TEXT [--reply-to MSG_ID] [--reply-to-sender JID]`

View File

@ -118,6 +118,7 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
var limit int
var afterStr string
var beforeStr string
var hasMedia bool
var msgType string
cmd := &cobra.Command{
@ -152,13 +153,14 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
}
msgs, err := a.DB().SearchMessages(store.SearchMessagesParams{
Query: args[0],
ChatJID: chat,
From: from,
Limit: limit,
After: after,
Before: before,
Type: msgType,
Query: args[0],
ChatJID: chat,
From: from,
Limit: limit,
After: after,
Before: before,
HasMedia: hasMedia,
Type: msgType,
})
if err != nil {
return err
@ -186,7 +188,8 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&msgType, "type", "", "media type filter (image|video|audio|document)")
cmd.Flags().BoolVar(&hasMedia, "has-media", false, "only messages with media")
cmd.Flags().StringVar(&msgType, "type", "", "message type filter (text|image|video|audio|document)")
return cmd
}

View File

@ -95,3 +95,15 @@ func TestWriteMessagesListFullOutput(t *testing.T) {
t.Fatalf("expected display text, got output:\n%s", full.String())
}
}
func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
cmd := newMessagesSearchCmd(&rootFlags{})
for _, name := range []string{"has-media", "type"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
if got := cmd.Flags().Lookup("type").Usage; !strings.Contains(got, "text|image|video|audio|document") {
t.Fatalf("type usage = %q", got)
}
}

View File

@ -7,19 +7,27 @@ import (
)
type SearchMessagesParams struct {
Query string
ChatJID string
From string
Limit int
Before *time.Time
After *time.Time
Type string
Query string
ChatJID string
From string
Limit int
Before *time.Time
After *time.Time
HasMedia bool
Type string
}
func (d *DB) SearchMessages(p SearchMessagesParams) ([]Message, error) {
if strings.TrimSpace(p.Query) == "" {
return nil, fmt.Errorf("query is required")
}
msgType := normalizedMessageType(p.Type)
if msgType != "" && !validSearchMessageType(msgType) {
return nil, fmt.Errorf("unsupported message type %q", p.Type)
}
if p.HasMedia && msgType == "text" {
return nil, fmt.Errorf("cannot combine has-media with type=text")
}
if p.Limit <= 0 {
p.Limit = 50
}
@ -111,9 +119,29 @@ func applyMessageFilters(query string, args []interface{}, p SearchMessagesParam
query += " AND m.ts < ?"
args = append(args, unix(*p.Before))
}
if strings.TrimSpace(p.Type) != "" {
query += " AND COALESCE(m.media_type,'') = ?"
args = append(args, p.Type)
if p.HasMedia {
query += " AND COALESCE(m.media_type,'') != ''"
}
if msgType := normalizedMessageType(p.Type); msgType != "" {
if msgType == "text" {
query += " AND COALESCE(m.media_type,'') = ''"
} else {
query += " AND LOWER(COALESCE(m.media_type,'')) = ?"
args = append(args, msgType)
}
}
return query, args
}
func normalizedMessageType(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func validSearchMessageType(s string) bool {
switch s {
case "text", "image", "video", "audio", "document":
return true
default:
return false
}
}

View File

@ -0,0 +1,132 @@
package store
import (
"strings"
"testing"
"time"
)
func TestSearchMessagesFiltersByMediaAndType(t *testing.T) {
db := openTestDB(t)
chat := "123@s.whatsapp.net"
if err := db.UpsertChat(chat, "dm", "Alice", time.Now()); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
base := time.Date(2024, 4, 1, 0, 0, 0, 0, time.UTC)
rows := []UpsertMessageParams{
{
ChatJID: chat,
ChatName: "Alice",
MsgID: "text-1",
SenderJID: chat,
SenderName: "Alice",
Timestamp: base,
Text: "quarterly report ready",
},
{
ChatJID: chat,
ChatName: "Alice",
MsgID: "image-1",
SenderJID: chat,
SenderName: "Alice",
Timestamp: base.Add(time.Second),
MediaType: "image",
MediaCaption: "quarterly report screenshot",
Filename: "report.png",
MimeType: "image/png",
},
{
ChatJID: chat,
ChatName: "Alice",
MsgID: "document-1",
SenderJID: chat,
SenderName: "Alice",
Timestamp: base.Add(2 * time.Second),
MediaType: "document",
MediaCaption: "quarterly report attachment",
Filename: "report.pdf",
MimeType: "application/pdf",
},
}
for _, row := range rows {
if err := db.UpsertMessage(row); err != nil {
t.Fatalf("UpsertMessage %s: %v", row.MsgID, err)
}
}
tests := []struct {
name string
p SearchMessagesParams
want string
}{
{
name: "all matches",
p: SearchMessagesParams{Query: "report", Limit: 10},
want: "document-1,image-1,text-1",
},
{
name: "has media",
p: SearchMessagesParams{Query: "report", Limit: 10, HasMedia: true},
want: "document-1,image-1",
},
{
name: "text type",
p: SearchMessagesParams{Query: "report", Limit: 10, Type: " text "},
want: "text-1",
},
{
name: "image type case insensitive",
p: SearchMessagesParams{Query: "report", Limit: 10, Type: "IMAGE"},
want: "image-1",
},
{
name: "has media plus concrete media type",
p: SearchMessagesParams{Query: "report", Limit: 10, HasMedia: true, Type: "document"},
want: "document-1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := db.SearchMessages(tc.p)
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if ids := messageIDs(got); ids != tc.want {
t.Fatalf("ids = %q, want %q", ids, tc.want)
}
})
}
}
func TestSearchMessagesRejectsInvalidMediaFilters(t *testing.T) {
db := openTestDB(t)
tests := []struct {
name string
p SearchMessagesParams
wantErr string
}{
{
name: "contradictory text and has media",
p: SearchMessagesParams{Query: "report", HasMedia: true, Type: "text"},
wantErr: "cannot combine",
},
{
name: "unsupported type",
p: SearchMessagesParams{Query: "report", Type: "sticker"},
wantErr: "unsupported message type",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := db.SearchMessages(tc.p)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tc.wantErr)
}
})
}
}