feat: expose forwarded message metadata

This commit is contained in:
Peter Steinberger 2026-05-04 07:05:28 +01:00
parent fca5b96138
commit 7533e4bef9
No known key found for this signature in database
16 changed files with 283 additions and 88 deletions

View File

@ -26,6 +26,7 @@
- Groups: hide groups after `groups leave`, mark missing joined groups as left during refresh, and show them again if a later refresh reports membership. (#125, #129 — thanks @SeifBenayed and @ImLukeF)
- History: cap on-demand backfill at 500 messages per request and 100 requests per run.
- Messages: normalize device-specific `@s.whatsapp.net` JIDs before storing chats, contacts, and senders.
- Messages: store forwarded-message metadata and add `--forwarded` filters for list/search. (#24 — thanks @bnvyas)
- Doctor: report lock owner PID and distinguish paired stores locked by another process. (#105 — thanks @artemgetmann)
- Media: recover panics per download job so one bad payload no longer drains the worker pool. (#179 — thanks @shaun0927)
- Messages: attribute history messages from LID-addressed groups to the top-level participant sender. (#19 — thanks @entropyy0)

View File

@ -32,6 +32,7 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
var fromMe bool
var fromThem bool
var asc bool
var forwarded bool
cmd := &cobra.Command{
Use: "list",
@ -85,6 +86,7 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
Before: before,
FromMe: fromMeFilter,
Asc: asc,
Forwarded: forwarded,
})
if err != nil {
return err
@ -109,6 +111,7 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&fromMe, "from-me", false, "only messages sent by me")
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")
return cmd
}
@ -120,6 +123,7 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
var beforeStr string
var hasMedia bool
var msgType string
var forwarded bool
cmd := &cobra.Command{
Use: "search <query>",
@ -153,14 +157,15 @@ 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,
HasMedia: hasMedia,
Type: msgType,
Query: args[0],
ChatJID: chat,
From: from,
Limit: limit,
After: after,
Before: before,
HasMedia: hasMedia,
Type: msgType,
Forwarded: forwarded,
})
if err != nil {
return err
@ -190,6 +195,7 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
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")
return cmd
}

View File

@ -62,6 +62,12 @@ func writeMessageShow(dst io.Writer, m store.Message) error {
if m.MediaType != "" {
fmt.Fprintf(dst, "Media: %s\n", m.MediaType)
}
if m.IsForwarded {
fmt.Fprintln(dst, "Forwarded: yes")
if m.ForwardingScore > 0 {
fmt.Fprintf(dst, "Forwarding score: %d\n", m.ForwardingScore)
}
}
fmt.Fprintf(dst, "\n%s\n", m.Text)
return nil
}

View File

@ -98,7 +98,7 @@ func TestWriteMessagesListFullOutput(t *testing.T) {
func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
cmd := newMessagesSearchCmd(&rootFlags{})
for _, name := range []string{"has-media", "type"} {
for _, name := range []string{"has-media", "type", "forwarded"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
@ -107,3 +107,33 @@ func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
t.Fatalf("type usage = %q", got)
}
}
func TestMessagesListCommandExposesForwardedFilter(t *testing.T) {
cmd := newMessagesListCmd(&rootFlags{})
if cmd.Flags().Lookup("forwarded") == nil {
t.Fatalf("expected --forwarded flag")
}
}
func TestWriteMessageShowIncludesForwardedMetadata(t *testing.T) {
msg := store.Message{
ChatJID: "chat@s.whatsapp.net",
SenderJID: "sender@s.whatsapp.net",
MsgID: "mid",
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Text: "hello",
IsForwarded: true,
ForwardingScore: 3,
}
var out bytes.Buffer
if err := writeMessageShow(&out, msg); err != nil {
t.Fatalf("writeMessageShow: %v", err)
}
if !strings.Contains(out.String(), "Forwarded: yes") {
t.Fatalf("expected forwarded marker, got:\n%s", out.String())
}
if !strings.Contains(out.String(), "Forwarding score: 3") {
t.Fatalf("expected forwarding score, got:\n%s", out.String())
}
}

View File

@ -203,24 +203,26 @@ func (a *App) storeParsedMessage(ctx context.Context, pm wa.ParsedMessage) error
displayText := a.buildDisplayText(ctx, pm)
return a.db.UpsertMessage(store.UpsertMessageParams{
ChatJID: chatJID,
ChatName: chatName,
MsgID: pm.ID,
SenderJID: senderJID,
SenderName: senderName,
Timestamp: pm.Timestamp,
FromMe: pm.FromMe,
Text: pm.Text,
DisplayText: displayText,
MediaType: mediaType,
MediaCaption: caption,
Filename: filename,
MimeType: mimeType,
DirectPath: directPath,
MediaKey: mediaKey,
FileSHA256: fileSha,
FileEncSHA256: fileEncSha,
FileLength: fileLen,
ChatJID: chatJID,
ChatName: chatName,
MsgID: pm.ID,
SenderJID: senderJID,
SenderName: senderName,
Timestamp: pm.Timestamp,
FromMe: pm.FromMe,
Text: pm.Text,
DisplayText: displayText,
IsForwarded: pm.IsForwarded,
ForwardingScore: pm.ForwardingScore,
MediaType: mediaType,
MediaCaption: caption,
Filename: filename,
MimeType: mimeType,
DirectPath: directPath,
MediaKey: mediaKey,
FileSHA256: fileSha,
FileEncSHA256: fileEncSha,
FileLength: fileLen,
})
}

View File

@ -211,6 +211,37 @@ func TestStoreParsedMessageResolvesLIDChatAndSender(t *testing.T) {
}
}
func TestStoreParsedMessageStoresForwardedMetadata(t *testing.T) {
a := newTestApp(t)
f := newFakeWA()
a.wa = f
chat := types.JID{User: "123", Server: types.DefaultUserServer}
err := a.storeParsedMessage(context.Background(), wa.ParsedMessage{
Chat: chat,
ID: "m-forwarded",
SenderJID: chat.String(),
Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
Text: "forwarded",
IsForwarded: true,
ForwardingScore: 4,
})
if err != nil {
t.Fatalf("storeParsedMessage: %v", err)
}
msg, err := a.db.GetMessage(chat.String(), "m-forwarded")
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if !msg.IsForwarded {
t.Fatalf("expected forwarded message, got %+v", msg)
}
if msg.ForwardingScore != 4 {
t.Fatalf("ForwardingScore = %d, want 4", msg.ForwardingScore)
}
}
func TestSyncStoresDisplayText(t *testing.T) {
a := newTestApp(t)
f := newFakeWA()

View File

@ -7,33 +7,36 @@ import (
)
type UpsertMessageParams struct {
ChatJID string
ChatName string
MsgID string
SenderJID string
SenderName string
Timestamp time.Time
FromMe bool
Text string
DisplayText string
MediaType string
MediaCaption string
Filename string
MimeType string
DirectPath string
MediaKey []byte
FileSHA256 []byte
FileEncSHA256 []byte
FileLength uint64
ChatJID string
ChatName string
MsgID string
SenderJID string
SenderName string
Timestamp time.Time
FromMe bool
Text string
DisplayText string
IsForwarded bool
ForwardingScore uint32
MediaType string
MediaCaption string
Filename string
MimeType string
DirectPath string
MediaKey []byte
FileSHA256 []byte
FileEncSHA256 []byte
FileLength uint64
}
func (d *DB) UpsertMessage(p UpsertMessageParams) error {
_, err := d.sql.Exec(`
INSERT INTO messages(
chat_jid, chat_name, msg_id, sender_jid, sender_name, ts, from_me, text, display_text,
is_forwarded, forwarding_score,
media_type, media_caption, filename, mime_type, direct_path,
media_key, file_sha256, file_enc_sha256, file_length
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_jid, msg_id) DO UPDATE SET
chat_name=COALESCE(NULLIF(excluded.chat_name,''), messages.chat_name),
sender_jid=excluded.sender_jid,
@ -42,6 +45,8 @@ func (d *DB) UpsertMessage(p UpsertMessageParams) error {
from_me=excluded.from_me,
text=excluded.text,
display_text=CASE WHEN excluded.display_text IS NOT NULL AND excluded.display_text != '' THEN excluded.display_text ELSE messages.display_text END,
is_forwarded=excluded.is_forwarded,
forwarding_score=excluded.forwarding_score,
media_type=excluded.media_type,
media_caption=excluded.media_caption,
filename=COALESCE(NULLIF(excluded.filename,''), messages.filename),
@ -52,6 +57,7 @@ func (d *DB) UpsertMessage(p UpsertMessageParams) error {
file_enc_sha256=CASE WHEN excluded.file_enc_sha256 IS NOT NULL AND length(excluded.file_enc_sha256)>0 THEN excluded.file_enc_sha256 ELSE messages.file_enc_sha256 END,
file_length=CASE WHEN excluded.file_length>0 THEN excluded.file_length ELSE messages.file_length END
`, p.ChatJID, nullIfEmpty(p.ChatName), p.MsgID, nullIfEmpty(p.SenderJID), nullIfEmpty(p.SenderName), unix(p.Timestamp), boolToInt(p.FromMe), nullIfEmpty(p.Text), nullIfEmpty(p.DisplayText),
boolToInt(p.IsForwarded), int64(p.ForwardingScore),
nullIfEmpty(p.MediaType), nullIfEmpty(p.MediaCaption), nullIfEmpty(p.Filename), nullIfEmpty(p.MimeType), nullIfEmpty(p.DirectPath),
p.MediaKey, p.FileSHA256, p.FileEncSHA256, int64(p.FileLength),
)
@ -66,6 +72,7 @@ type ListMessagesParams struct {
After *time.Time
FromMe *bool
Asc bool
Forwarded bool
}
func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
@ -73,7 +80,7 @@ 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,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.media_type,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE 1=1`
@ -98,6 +105,9 @@ func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
query += " AND m.from_me = ?"
args = append(args, boolToInt(*p.FromMe))
}
if p.Forwarded {
query += " AND m.is_forwarded = 1"
}
if p.Asc {
query += " ORDER BY m.ts ASC, m.rowid ASC LIMIT ?"
} else {
@ -109,7 +119,7 @@ func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
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,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.media_type,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.chat_jid = ? AND m.msg_id = ?
@ -117,11 +127,15 @@ func (d *DB) GetMessage(chatJID, msgID string) (Message, error) {
var m Message
var ts int64
var fromMe int
if err := row.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType, &m.Snippet); err != nil {
var forwarded int
var forwardingScore int64
if err := row.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &forwarded, &forwardingScore, &m.MediaType, &m.Snippet); err != nil {
return Message{}, err
}
m.Timestamp = fromUnix(ts)
m.FromMe = fromMe != 0
m.IsForwarded = forwarded != 0
m.ForwardingScore = uint32(forwardingScore)
return m, nil
}
@ -170,7 +184,7 @@ 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,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.media_type,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.chat_jid = ? AND (m.ts < ? OR (m.ts = ? AND m.rowid < ?))
@ -182,7 +196,7 @@ 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,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.media_type,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.chat_jid = ? AND (m.ts > ? OR (m.ts = ? AND m.rowid > ?))
@ -217,11 +231,15 @@ func (d *DB) scanMessages(query string, args ...interface{}) ([]Message, error)
var m Message
var ts int64
var fromMe int
if err := rows.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType, &m.Snippet); err != nil {
var forwarded int
var forwardingScore int64
if err := rows.Scan(&m.rowID, &m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &forwarded, &forwardingScore, &m.MediaType, &m.Snippet); err != nil {
return nil, err
}
m.Timestamp = fromUnix(ts)
m.FromMe = fromMe != 0
m.IsForwarded = forwarded != 0
m.ForwardingScore = uint32(forwardingScore)
out = append(out, m)
}
return out, rows.Err()

View File

@ -81,7 +81,8 @@ func TestListMessagesFiltersAndOrdering(t *testing.T) {
rows := []UpsertMessageParams{
{ChatJID: chat, MsgID: "old-from-alice", SenderJID: "alice@s.whatsapp.net", Timestamp: base, Text: "old"},
{ChatJID: chat, MsgID: "new-from-me", SenderJID: "me@s.whatsapp.net", Timestamp: base.Add(time.Second), FromMe: true, Text: "new"},
{ChatJID: otherChat, MsgID: "other-chat", SenderJID: "alice@s.whatsapp.net", Timestamp: base.Add(2 * time.Second), Text: "other"},
{ChatJID: chat, MsgID: "forwarded", SenderJID: "bob@s.whatsapp.net", Timestamp: base.Add(2 * time.Second), Text: "forwarded", IsForwarded: true, ForwardingScore: 2},
{ChatJID: otherChat, MsgID: "other-chat", SenderJID: "alice@s.whatsapp.net", Timestamp: base.Add(3 * time.Second), Text: "other"},
}
for _, row := range rows {
if err := db.UpsertMessage(row); err != nil {
@ -93,7 +94,7 @@ func TestListMessagesFiltersAndOrdering(t *testing.T) {
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if got := messageIDs(msgs); got != "new-from-me,old-from-alice" {
if got := messageIDs(msgs); got != "forwarded,new-from-me,old-from-alice" {
t.Fatalf("default order = %s", got)
}
@ -101,7 +102,7 @@ func TestListMessagesFiltersAndOrdering(t *testing.T) {
if err != nil {
t.Fatalf("ListMessages asc: %v", err)
}
if got := messageIDs(msgs); got != "old-from-alice,new-from-me" {
if got := messageIDs(msgs); got != "old-from-alice,new-from-me,forwarded" {
t.Fatalf("asc order = %s", got)
}
@ -121,6 +122,17 @@ func TestListMessagesFiltersAndOrdering(t *testing.T) {
if got := messageIDs(msgs); got != "old-from-alice" {
t.Fatalf("sender filter = %s", got)
}
msgs, err = db.ListMessages(ListMessagesParams{ChatJID: chat, Limit: 10, Forwarded: true})
if err != nil {
t.Fatalf("ListMessages forwarded: %v", err)
}
if got := messageIDs(msgs); got != "forwarded" {
t.Fatalf("forwarded filter = %s", got)
}
if msgs[0].ForwardingScore != 2 {
t.Fatalf("ForwardingScore = %d, want 2", msgs[0].ForwardingScore)
}
}
func TestListMessagesStableSameTimestampOrder(t *testing.T) {

View File

@ -18,6 +18,7 @@ var schemaMigrations = []migration{
{version: 2, name: "messages display_text column", up: migrateMessagesDisplayText},
{version: 3, name: "messages fts", up: migrateMessagesFTS},
{version: 4, name: "groups left_at column", up: migrateGroupsLeftAt},
{version: 5, name: "messages forwarded columns", up: migrateMessagesForwardedColumns},
}
func (d *DB) ensureSchema() error {
@ -97,6 +98,29 @@ func migrateMessagesDisplayText(d *DB) error {
return nil
}
func migrateMessagesForwardedColumns(d *DB) error {
hasForwarded, err := d.tableHasColumn("messages", "is_forwarded")
if err != nil {
return err
}
if !hasForwarded {
if _, err := d.sql.Exec(`ALTER TABLE messages ADD COLUMN is_forwarded INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("add messages.is_forwarded column: %w", err)
}
}
hasScore, err := d.tableHasColumn("messages", "forwarding_score")
if err != nil {
return err
}
if !hasScore {
if _, err := d.sql.Exec(`ALTER TABLE messages ADD COLUMN forwarding_score INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("add messages.forwarding_score column: %w", err)
}
}
return nil
}
func migrateMessagesFTS(d *DB) error {
ftsExists, err := d.tableExists("messages_fts")
if err != nil {

View File

@ -63,6 +63,8 @@ const coreSchemaSQL = `
from_me INTEGER NOT NULL,
text TEXT,
display_text TEXT,
is_forwarded INTEGER NOT NULL DEFAULT 0,
forwarding_score INTEGER NOT NULL DEFAULT 0,
media_type TEXT,
media_caption TEXT,
filename TEXT,

View File

@ -26,6 +26,8 @@ func TestOpenCreatesExpectedSchema(t *testing.T) {
"chat_name",
"sender_name",
"display_text",
"is_forwarded",
"forwarding_score",
"local_path",
"downloaded_at",
} {

View File

@ -7,14 +7,15 @@ import (
)
type SearchMessagesParams struct {
Query string
ChatJID string
From string
Limit int
Before *time.Time
After *time.Time
HasMedia bool
Type string
Query string
ChatJID string
From string
Limit int
Before *time.Time
After *time.Time
HasMedia bool
Type string
Forwarded bool
}
func (d *DB) SearchMessages(p SearchMessagesParams) ([]Message, error) {
@ -53,7 +54,7 @@ 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,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.media_type,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
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 '\')`
@ -86,7 +87,7 @@ 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,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''),
SELECT m.rowid, m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), m.is_forwarded, m.forwarding_score, COALESCE(m.media_type,''),
snippet(messages_fts, 0, '[', ']', '…', 12)
FROM messages_fts
JOIN messages m ON messages_fts.rowid = m.rowid
@ -122,6 +123,9 @@ func applyMessageFilters(query string, args []interface{}, p SearchMessagesParam
if p.HasMedia {
query += " AND COALESCE(m.media_type,'') != ''"
}
if p.Forwarded {
query += " AND m.is_forwarded = 1"
}
if msgType := normalizedMessageType(p.Type); msgType != "" {
if msgType == "text" {
query += " AND COALESCE(m.media_type,'') = ''"

View File

@ -49,6 +49,17 @@ func TestSearchMessagesFiltersByMediaAndType(t *testing.T) {
Filename: "report.pdf",
MimeType: "application/pdf",
},
{
ChatJID: chat,
ChatName: "Alice",
MsgID: "forwarded-1",
SenderJID: chat,
SenderName: "Alice",
Timestamp: base.Add(3 * time.Second),
Text: "forwarded memo",
IsForwarded: true,
ForwardingScore: 1,
},
}
for _, row := range rows {
if err := db.UpsertMessage(row); err != nil {
@ -86,6 +97,11 @@ func TestSearchMessagesFiltersByMediaAndType(t *testing.T) {
p: SearchMessagesParams{Query: "report", Limit: 10, HasMedia: true, Type: "document"},
want: "document-1",
},
{
name: "forwarded",
p: SearchMessagesParams{Query: "forwarded", Limit: 10, Forwarded: true},
want: "forwarded-1",
},
}
for _, tc := range tests {

View File

@ -47,17 +47,19 @@ type MediaDownloadInfo struct {
}
type Message struct {
ChatJID string
ChatName string
MsgID string
SenderJID string
Timestamp time.Time
FromMe bool
Text string
DisplayText string
MediaType string
Snippet string
rowID int64
ChatJID string
ChatName string
MsgID string
SenderJID string
Timestamp time.Time
FromMe bool
Text string
DisplayText string
IsForwarded bool
ForwardingScore uint32
MediaType string
Snippet string
rowID int64
}
type MessageInfo struct {

View File

@ -22,18 +22,20 @@ type Media struct {
}
type ParsedMessage struct {
Chat types.JID
ID string
SenderJID string
Timestamp time.Time
FromMe bool
Text string
Media *Media
PushName string
ReplyToID string
ReplyToDisplay string
ReactionToID string
ReactionEmoji string
Chat types.JID
ID string
SenderJID string
Timestamp time.Time
FromMe bool
Text string
Media *Media
PushName string
ReplyToID string
ReplyToDisplay string
ReactionToID string
ReactionEmoji string
IsForwarded bool
ForwardingScore uint32
}
func ParseLiveMessage(evt *events.Message) ParsedMessage {
@ -97,6 +99,8 @@ func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
if quoted := ctx.GetQuotedMessage(); quoted != nil {
pm.ReplyToDisplay = strings.TrimSpace(displayTextForProto(quoted))
}
pm.ForwardingScore = ctx.GetForwardingScore()
pm.IsForwarded = ctx.GetIsForwarded() || pm.ForwardingScore > 0
}
}

View File

@ -180,6 +180,41 @@ func TestParseLiveMessageReply(t *testing.T) {
}
}
func TestParseLiveMessageForwarded(t *testing.T) {
chat, _ := types.ParseJID("123@s.whatsapp.net")
sender, _ := types.ParseJID("sender@s.whatsapp.net")
ev := &events.Message{
Info: types.MessageInfo{
MessageSource: types.MessageSource{
Chat: chat,
Sender: sender,
IsFromMe: false,
},
ID: "mid",
Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
PushName: "Sender",
},
Message: &waProto.Message{
ExtendedTextMessage: &waProto.ExtendedTextMessage{
Text: proto.String("forwarded text"),
ContextInfo: &waProto.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(3),
},
},
},
}
pm := ParseLiveMessage(ev)
if !pm.IsForwarded {
t.Fatalf("expected forwarded message, got %+v", pm)
}
if pm.ForwardingScore != 3 {
t.Fatalf("ForwardingScore = %d, want 3", pm.ForwardingScore)
}
}
func TestParseTemplateMessage(t *testing.T) {
chat, _ := types.ParseJID("123@s.whatsapp.net")
sender, _ := types.ParseJID("biz@s.whatsapp.net")