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:
parent
aa00e0a58e
commit
54d44b34fc
@ -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
|
||||
|
||||
@ -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]`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
132
internal/store/search_filters_test.go
Normal file
132
internal/store/search_filters_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user