Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
3a2561aa11 fix: keep captions in message output (#5) (thanks @zats) 2026-01-23 02:04:00 +00:00
Sash Zats
d360a966f9 Add display text for reactions and replies 2026-01-23 02:03:26 +00:00
11 changed files with 628 additions and 55 deletions

View File

@ -6,6 +6,7 @@
- Start tracking changes for the next release (from 2026-01-23).
- Send: `wacli send file --filename` to override display name for uploads. (#7 — thanks @plattenschieber)
- Messages: preserve captions in list/search output while still indexing display text for reactions/media. (#5 — thanks @zats)
### Fixed

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
@ -88,9 +89,12 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
if chatLabel == "" {
chatLabel = m.ChatJID
}
text := m.Text
text := strings.TrimSpace(m.Text)
if text == "" {
text = strings.TrimSpace(m.DisplayText)
}
if m.MediaType != "" && text == "" {
text = "[" + m.MediaType + "]"
text = "Sent " + m.MediaType
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
@ -183,6 +187,12 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
chatLabel = m.ChatJID
}
match := m.Snippet
if match == "" {
match = strings.TrimSpace(m.Text)
}
if match == "" {
match = strings.TrimSpace(m.DisplayText)
}
if match == "" {
match = m.Text
}

View File

@ -12,6 +12,7 @@ import (
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
)
type WAClient interface {
@ -41,6 +42,7 @@ type WAClient interface {
Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
DownloadMediaToFile(ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength uint64, mediaType, mmsType string, targetPath string) (int64, error)
DecryptReaction(ctx context.Context, reaction *events.Message) (*waProto.ReactionMessage, error)
RequestHistorySyncOnDemand(ctx context.Context, lastKnown types.MessageInfo, count int) (types.MessageID, error)
Logout(ctx context.Context) error
}

View File

@ -216,6 +216,10 @@ func (f *fakeWA) Upload(ctx context.Context, data []byte, mediaType whatsmeow.Me
return whatsmeow.UploadResponse{}, nil
}
func (f *fakeWA) DecryptReaction(ctx context.Context, reaction *events.Message) (*waProto.ReactionMessage, error) {
return nil, fmt.Errorf("not supported")
}
func (f *fakeWA) DownloadMediaToFile(ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength uint64, mediaType, mmsType string, targetPath string) (int64, error) {
if err := os.MkdirAll(filepath.Dir(targetPath), 0o700); err != nil {
return 0, err

View File

@ -85,6 +85,16 @@ func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
switch v := evt.(type) {
case *events.Message:
pm := wa.ParseLiveMessage(v)
if pm.ReactionToID != "" && pm.ReactionEmoji == "" && v.Message != nil && v.Message.GetEncReactionMessage() != nil {
if reaction, err := a.wa.DecryptReaction(ctx, v); err == nil && reaction != nil {
pm.ReactionEmoji = reaction.GetText()
if pm.ReactionToID == "" {
if key := reaction.GetKey(); key != nil {
pm.ReactionToID = key.GetID()
}
}
}
}
if err := a.storeParsedMessage(ctx, pm); err == nil {
messagesStored.Add(1)
}
@ -295,6 +305,8 @@ func (a *App) storeParsedMessage(ctx context.Context, pm wa.ParsedMessage) error
fileLen = pm.Media.FileLength
}
displayText := a.buildDisplayText(ctx, pm)
return a.db.UpsertMessage(store.UpsertMessageParams{
ChatJID: chatJID,
ChatName: chatName,
@ -304,6 +316,7 @@ func (a *App) storeParsedMessage(ctx context.Context, pm wa.ParsedMessage) error
Timestamp: pm.Timestamp,
FromMe: pm.FromMe,
Text: pm.Text,
DisplayText: displayText,
MediaType: mediaType,
MediaCaption: caption,
Filename: filename,
@ -315,3 +328,100 @@ func (a *App) storeParsedMessage(ctx context.Context, pm wa.ParsedMessage) error
FileLength: fileLen,
})
}
func (a *App) buildDisplayText(ctx context.Context, pm wa.ParsedMessage) string {
base := baseDisplayText(pm)
if pm.ReactionToID != "" || strings.TrimSpace(pm.ReactionEmoji) != "" {
target := strings.TrimSpace(pm.ReactionToID)
display := ""
if target != "" {
display = a.lookupMessageDisplayText(pm.Chat.String(), target)
}
if display == "" {
display = "message"
}
emoji := strings.TrimSpace(pm.ReactionEmoji)
if emoji != "" {
return fmt.Sprintf("Reacted %s to %s", emoji, display)
}
return fmt.Sprintf("Reacted to %s", display)
}
if pm.ReplyToID != "" {
quoted := strings.TrimSpace(pm.ReplyToDisplay)
if quoted == "" {
quoted = a.lookupMessageDisplayText(pm.Chat.String(), pm.ReplyToID)
}
if quoted == "" {
quoted = "message"
}
if base == "" {
base = "(message)"
}
return fmt.Sprintf("> %s\n%s", quoted, base)
}
if base == "" {
base = "(message)"
}
return base
}
func baseDisplayText(pm wa.ParsedMessage) string {
if pm.Media != nil {
return "Sent " + mediaLabel(pm.Media.Type)
}
if text := strings.TrimSpace(pm.Text); text != "" {
return text
}
return ""
}
func (a *App) lookupMessageDisplayText(chatJID, msgID string) string {
if strings.TrimSpace(chatJID) == "" || strings.TrimSpace(msgID) == "" {
return ""
}
msg, err := a.db.GetMessage(chatJID, msgID)
if err != nil {
return ""
}
if text := strings.TrimSpace(msg.DisplayText); text != "" {
return text
}
if text := strings.TrimSpace(msg.Text); text != "" {
return text
}
if strings.TrimSpace(msg.MediaType) != "" {
return "Sent " + mediaLabel(msg.MediaType)
}
return ""
}
func mediaLabel(mediaType string) string {
mt := strings.ToLower(strings.TrimSpace(mediaType))
switch mt {
case "gif":
return "gif"
case "image":
return "image"
case "video":
return "video"
case "audio":
return "audio"
case "sticker":
return "sticker"
case "document":
return "document"
case "location":
return "location"
case "contact":
return "contact"
case "contacts":
return "contacts"
case "":
return "message"
default:
return mt
}
}

View File

@ -85,6 +85,156 @@ func TestSyncStoresLiveAndHistoryMessages(t *testing.T) {
}
}
func TestSyncStoresDisplayText(t *testing.T) {
a := newTestApp(t)
f := newFakeWA()
a.wa = f
chat := types.JID{User: "123", Server: types.DefaultUserServer}
f.contacts[chat.ToNonAD()] = types.ContactInfo{
Found: true,
FullName: "Alice",
FirstName: "Alice",
PushName: "Alice",
}
base := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
textMsg := &events.Message{
Info: types.MessageInfo{
MessageSource: types.MessageSource{
Chat: chat,
Sender: chat,
IsFromMe: false,
IsGroup: false,
},
ID: "m-text",
Timestamp: base.Add(1 * time.Second),
PushName: "Alice",
},
Message: &waProto.Message{Conversation: proto.String("hello")},
}
imageMsg := &events.Message{
Info: types.MessageInfo{
MessageSource: types.MessageSource{
Chat: chat,
Sender: chat,
IsFromMe: false,
IsGroup: false,
},
ID: "m-image",
Timestamp: base.Add(2 * time.Second),
PushName: "Alice",
},
Message: &waProto.Message{
ImageMessage: &waProto.ImageMessage{
Mimetype: proto.String("image/jpeg"),
DirectPath: proto.String("/direct"),
MediaKey: []byte{1},
FileSHA256: []byte{2},
FileEncSHA256: []byte{3},
FileLength: proto.Uint64(10),
},
},
}
replyMsg := &events.Message{
Info: types.MessageInfo{
MessageSource: types.MessageSource{
Chat: chat,
Sender: chat,
IsFromMe: false,
IsGroup: false,
},
ID: "m-reply",
Timestamp: base.Add(3 * time.Second),
PushName: "Alice",
},
Message: &waProto.Message{
ExtendedTextMessage: &waProto.ExtendedTextMessage{
Text: proto.String("reply text"),
ContextInfo: &waProto.ContextInfo{
StanzaID: proto.String("m-text"),
QuotedMessage: &waProto.Message{
Conversation: proto.String("quoted text"),
},
},
},
},
}
reactionMsg := &events.Message{
Info: types.MessageInfo{
MessageSource: types.MessageSource{
Chat: chat,
Sender: chat,
IsFromMe: false,
IsGroup: false,
},
ID: "m-react",
Timestamp: base.Add(4 * time.Second),
PushName: "Alice",
},
Message: &waProto.Message{
ReactionMessage: &waProto.ReactionMessage{
Text: proto.String("👍"),
Key: &waProto.MessageKey{ID: proto.String("m-text")},
},
},
}
f.connectEvents = []interface{}{textMsg, imageMsg, replyMsg, reactionMsg}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
res, err := a.Sync(ctx, SyncOptions{
Mode: SyncModeFollow,
AllowQR: false,
})
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.MessagesStored != 4 {
t.Fatalf("expected 4 MessagesStored, got %d", res.MessagesStored)
}
msg, err := a.db.GetMessage(chat.String(), "m-text")
if err != nil {
t.Fatalf("GetMessage text: %v", err)
}
if msg.DisplayText != "hello" {
t.Fatalf("expected display text 'hello', got %q", msg.DisplayText)
}
msg, err = a.db.GetMessage(chat.String(), "m-image")
if err != nil {
t.Fatalf("GetMessage image: %v", err)
}
if msg.DisplayText != "Sent image" {
t.Fatalf("expected display text 'Sent image', got %q", msg.DisplayText)
}
msg, err = a.db.GetMessage(chat.String(), "m-reply")
if err != nil {
t.Fatalf("GetMessage reply: %v", err)
}
if msg.DisplayText != "> quoted text\nreply text" {
t.Fatalf("unexpected reply display text: %q", msg.DisplayText)
}
msg, err = a.db.GetMessage(chat.String(), "m-react")
if err != nil {
t.Fatalf("GetMessage react: %v", err)
}
if msg.DisplayText != "Reacted 👍 to hello" {
t.Fatalf("unexpected reaction display text: %q", msg.DisplayText)
}
}
func TestSyncOnceIdleExit(t *testing.T) {
a := newTestApp(t)
f := newFakeWA()

View File

@ -25,6 +25,7 @@ func TestOpenCreatesExpectedSchema(t *testing.T) {
for _, want := range []string{
"chat_name",
"sender_name",
"display_text",
"local_path",
"downloaded_at",
} {

View File

@ -119,6 +119,7 @@ func (d *DB) ensureSchema() error {
ts INTEGER NOT NULL,
from_me INTEGER NOT NULL,
text TEXT,
display_text TEXT,
media_type TEXT,
media_caption TEXT,
filename TEXT,
@ -140,19 +141,67 @@ func (d *DB) ensureSchema() error {
return fmt.Errorf("create tables: %w", err)
}
if _, err := d.sql.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
text,
media_caption,
filename,
chat_name,
sender_name
);
`); err != nil {
// Continue without FTS (fallback to LIKE).
d.ftsEnabled = false
if err := d.ensureMessageColumns(); err != nil {
return err
}
if err := d.ensureMessagesFTS(); err != nil {
return err
}
return nil
}
func (d *DB) ensureMessageColumns() error {
ok, err := d.tableHasColumn("messages", "display_text")
if err != nil {
return err
}
if ok {
return nil
}
if _, err := d.sql.Exec(`ALTER TABLE messages ADD COLUMN display_text TEXT`); err != nil {
return fmt.Errorf("add display_text column: %w", err)
}
return nil
}
func (d *DB) ensureMessagesFTS() error {
ftsExists, err := d.tableExists("messages_fts")
if err != nil {
return err
}
if ftsExists {
hasDisplay, err := d.tableHasColumn("messages_fts", "display_text")
if err != nil {
return err
}
if !hasDisplay {
if _, err := d.sql.Exec(`DROP TABLE IF EXISTS messages_fts`); err != nil {
return fmt.Errorf("drop messages_fts: %w", err)
}
ftsExists = false
}
}
created := false
if !ftsExists {
if _, err := d.sql.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
text,
media_caption,
filename,
chat_name,
sender_name,
display_text
);
`); err != nil {
// Continue without FTS (fallback to LIKE).
d.ftsEnabled = false
return nil
}
created = true
}
// Ensure triggers match our expected semantics (FTS5 supports DELETE directly).
if _, err := d.sql.Exec(`
@ -161,8 +210,8 @@ func (d *DB) ensureSchema() error {
DROP TRIGGER IF EXISTS messages_au;
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name)
VALUES (new.rowid, COALESCE(new.text,''), COALESCE(new.media_caption,''), COALESCE(new.filename,''), COALESCE(new.chat_name,''), COALESCE(new.sender_name,''));
INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name, display_text)
VALUES (new.rowid, COALESCE(new.text,''), COALESCE(new.media_caption,''), COALESCE(new.filename,''), COALESCE(new.chat_name,''), COALESCE(new.sender_name,''), COALESCE(new.display_text,''));
END;
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
@ -171,18 +220,70 @@ func (d *DB) ensureSchema() error {
CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.rowid;
INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name)
VALUES (new.rowid, COALESCE(new.text,''), COALESCE(new.media_caption,''), COALESCE(new.filename,''), COALESCE(new.chat_name,''), COALESCE(new.sender_name,''));
INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name, display_text)
VALUES (new.rowid, COALESCE(new.text,''), COALESCE(new.media_caption,''), COALESCE(new.filename,''), COALESCE(new.chat_name,''), COALESCE(new.sender_name,''), COALESCE(new.display_text,''));
END;
`); err != nil {
d.ftsEnabled = false
return nil
}
if created {
if _, err := d.sql.Exec(`
INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name, display_text)
SELECT rowid,
COALESCE(text,''),
COALESCE(media_caption,''),
COALESCE(filename,''),
COALESCE(chat_name,''),
COALESCE(sender_name,''),
COALESCE(display_text,'')
FROM messages;
`); err != nil {
d.ftsEnabled = false
return nil
}
}
d.ftsEnabled = true
return nil
}
func (d *DB) tableExists(table string) (bool, error) {
row := d.sql.QueryRow(`SELECT 1 FROM sqlite_master WHERE name = ? AND type IN ('table','view')`, table)
var one int
if err := row.Scan(&one); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return true, nil
}
func (d *DB) tableHasColumn(table, column string) (bool, error) {
rows, err := d.sql.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var name string
var colType string
var notNull int
var pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &colType, &notNull, &dflt, &pk); err != nil {
return false, err
}
if strings.EqualFold(name, column) {
return true, nil
}
}
return false, rows.Err()
}
// --- domain types + helpers
type Chat struct {
@ -224,15 +325,16 @@ type MediaDownloadInfo struct {
}
type Message struct {
ChatJID string
ChatName string
MsgID string
SenderJID string
Timestamp time.Time
FromMe bool
Text string
MediaType string
Snippet string
ChatJID string
ChatName string
MsgID string
SenderJID string
Timestamp time.Time
FromMe bool
Text string
DisplayText string
MediaType string
Snippet string
}
type MessageInfo struct {
@ -298,6 +400,7 @@ type UpsertMessageParams struct {
Timestamp time.Time
FromMe bool
Text string
DisplayText string
MediaType string
MediaCaption string
Filename string
@ -312,10 +415,10 @@ type UpsertMessageParams struct {
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,
chat_jid, chat_name, msg_id, sender_jid, sender_name, ts, from_me, text, display_text,
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,
@ -323,6 +426,7 @@ func (d *DB) UpsertMessage(p UpsertMessageParams) error {
ts=excluded.ts,
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,
media_type=excluded.media_type,
media_caption=excluded.media_caption,
filename=COALESCE(NULLIF(excluded.filename,''), messages.filename),
@ -332,7 +436,7 @@ func (d *DB) UpsertMessage(p UpsertMessageParams) error {
file_sha256=CASE WHEN excluded.file_sha256 IS NOT NULL AND length(excluded.file_sha256)>0 THEN excluded.file_sha256 ELSE messages.file_sha256 END,
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),
`, 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),
nullIfEmpty(p.MediaType), nullIfEmpty(p.MediaCaption), nullIfEmpty(p.Filename), nullIfEmpty(p.MimeType), nullIfEmpty(p.DirectPath),
p.MediaKey, p.FileSHA256, p.FileEncSHA256, int64(p.FileLength),
)
@ -359,7 +463,7 @@ func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
p.Limit = 50
}
query := `
SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.media_type,'')
SELECT 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,'')
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE 1=1`
@ -390,7 +494,7 @@ func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
var m Message
var ts int64
var fromMe int
if err := rows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.MediaType); err != nil {
if err := rows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType); err != nil {
return nil, err
}
m.Timestamp = fromUnix(ts)
@ -426,12 +530,12 @@ func (d *DB) SearchMessages(p SearchMessagesParams) ([]Message, error) {
func (d *DB) searchLIKE(p SearchMessagesParams) ([]Message, error) {
query := `
SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.media_type,''), ''
SELECT 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,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE (LOWER(m.text) LIKE LOWER(?) OR LOWER(m.media_caption) LIKE LOWER(?) OR LOWER(m.filename) LIKE LOWER(?) OR LOWER(COALESCE(m.chat_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(m.sender_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(c.name,'')) LIKE LOWER(?))`
WHERE (LOWER(m.text) LIKE LOWER(?) OR LOWER(m.display_text) LIKE LOWER(?) OR LOWER(m.media_caption) LIKE LOWER(?) OR LOWER(m.filename) LIKE LOWER(?) OR LOWER(COALESCE(m.chat_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(m.sender_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(c.name,'')) LIKE LOWER(?))`
needle := "%" + p.Query + "%"
args := []interface{}{needle, needle, needle, needle, needle, needle}
args := []interface{}{needle, needle, needle, needle, needle, needle, needle}
query, args = applyMessageFilters(query, args, p)
query += " ORDER BY m.ts DESC LIMIT ?"
args = append(args, p.Limit)
@ -440,7 +544,7 @@ func (d *DB) searchLIKE(p SearchMessagesParams) ([]Message, error) {
func (d *DB) searchFTS(p SearchMessagesParams) ([]Message, error) {
query := `
SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.media_type,''),
SELECT 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,''),
snippet(messages_fts, 0, '[', ']', '…', 12)
FROM messages_fts
JOIN messages m ON messages_fts.rowid = m.rowid
@ -489,7 +593,7 @@ func (d *DB) scanMessages(query string, args ...interface{}) ([]Message, error)
var m Message
var ts int64
var fromMe int
if err := rows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.MediaType, &m.Snippet); err != nil {
if err := rows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType, &m.Snippet); err != nil {
return nil, err
}
m.Timestamp = fromUnix(ts)
@ -501,7 +605,7 @@ func (d *DB) scanMessages(query string, args ...interface{}) ([]Message, error)
func (d *DB) GetMessage(chatJID, msgID string) (Message, error) {
row := d.sql.QueryRow(`
SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.media_type,'')
SELECT 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,'')
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.chat_jid = ? AND m.msg_id = ?
@ -509,7 +613,7 @@ func (d *DB) GetMessage(chatJID, msgID string) (Message, error) {
var m Message
var ts int64
var fromMe int
if err := row.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.MediaType); err != nil {
if err := row.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType); err != nil {
return Message{}, err
}
m.Timestamp = fromUnix(ts)
@ -618,7 +722,7 @@ func (d *DB) MessageContext(chatJID, msgID string, before, after int) ([]Message
}
beforeRows, err := d.sql.Query(`
SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.media_type,''), ''
SELECT 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,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.chat_jid = ? AND m.ts < ?
@ -635,7 +739,7 @@ func (d *DB) MessageContext(chatJID, msgID string, before, after int) ([]Message
var m Message
var ts int64
var fromMe int
if err := beforeRows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.MediaType, &m.Snippet); err != nil {
if err := beforeRows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType, &m.Snippet); err != nil {
return nil, err
}
m.Timestamp = fromUnix(ts)
@ -647,7 +751,7 @@ func (d *DB) MessageContext(chatJID, msgID string, before, after int) ([]Message
}
afterRows, err := d.sql.Query(`
SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.media_type,''), ''
SELECT 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,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.chat_jid = ? AND m.ts > ?
@ -664,7 +768,7 @@ func (d *DB) MessageContext(chatJID, msgID string, before, after int) ([]Message
var m Message
var ts int64
var fromMe int
if err := afterRows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.MediaType, &m.Snippet); err != nil {
if err := afterRows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType, &m.Snippet); err != nil {
return nil, err
}
m.Timestamp = fromUnix(ts)

View File

@ -14,6 +14,7 @@ import (
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
)
@ -206,6 +207,16 @@ func (c *Client) Upload(ctx context.Context, data []byte, mediaType whatsmeow.Me
return cli.Upload(ctx, data, mediaType)
}
func (c *Client) DecryptReaction(ctx context.Context, reaction *events.Message) (*waProto.ReactionMessage, error) {
c.mu.Lock()
cli := c.client
c.mu.Unlock()
if cli == nil || !cli.IsConnected() {
return nil, fmt.Errorf("not connected")
}
return cli.DecryptReaction(ctx, reaction)
}
func (c *Client) RequestHistorySyncOnDemand(ctx context.Context, lastKnown types.MessageInfo, count int) (types.MessageID, error) {
c.mu.Lock()
cli := c.client

View File

@ -22,14 +22,18 @@ type Media struct {
}
type ParsedMessage struct {
Chat types.JID
ID string
SenderJID string
Timestamp time.Time
FromMe bool
Text string
Media *Media
PushName 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
}
func ParseLiveMessage(evt *events.Message) ParsedMessage {
@ -78,6 +82,17 @@ func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
return
}
if reaction := m.GetReactionMessage(); reaction != nil {
pm.ReactionEmoji = reaction.GetText()
if key := reaction.GetKey(); key != nil {
pm.ReactionToID = key.GetID()
}
} else if encReaction := m.GetEncReactionMessage(); encReaction != nil {
if key := encReaction.GetTargetMessageKey(); key != nil {
pm.ReactionToID = key.GetID()
}
}
switch {
case m.GetConversation() != "":
pm.Text = m.GetConversation()
@ -99,15 +114,18 @@ func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
FileEncSHA256: clone(img.GetFileEncSHA256()),
FileLength: img.GetFileLength(),
}
return
}
if vid := m.GetVideoMessage(); vid != nil {
if pm.Text == "" {
pm.Text = vid.GetCaption()
}
mediaType := "video"
if vid.GetGifPlayback() {
mediaType = "gif"
}
pm.Media = &Media{
Type: "video",
Type: mediaType,
Caption: vid.GetCaption(),
MimeType: vid.GetMimetype(),
DirectPath: vid.GetDirectPath(),
@ -116,7 +134,6 @@ func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
FileEncSHA256: clone(vid.GetFileEncSHA256()),
FileLength: vid.GetFileLength(),
}
return
}
if aud := m.GetAudioMessage(); aud != nil {
@ -133,7 +150,6 @@ func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
FileEncSHA256: clone(aud.GetFileEncSHA256()),
FileLength: aud.GetFileLength(),
}
return
}
if doc := m.GetDocumentMessage(); doc != nil {
@ -151,7 +167,27 @@ func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
FileEncSHA256: clone(doc.GetFileEncSHA256()),
FileLength: doc.GetFileLength(),
}
return
}
if sticker := m.GetStickerMessage(); sticker != nil {
pm.Media = &Media{
Type: "sticker",
MimeType: sticker.GetMimetype(),
DirectPath: sticker.GetDirectPath(),
MediaKey: clone(sticker.GetMediaKey()),
FileSHA256: clone(sticker.GetFileSHA256()),
FileEncSHA256: clone(sticker.GetFileEncSHA256()),
FileLength: sticker.GetFileLength(),
}
}
if ctx := contextInfoForMessage(m); ctx != nil {
if id := strings.TrimSpace(ctx.GetStanzaID()); id != "" {
pm.ReplyToID = id
}
if quoted := ctx.GetQuotedMessage(); quoted != nil {
pm.ReplyToDisplay = strings.TrimSpace(displayTextForProto(quoted))
}
}
}
@ -163,3 +199,81 @@ func clone(b []byte) []byte {
copy(out, b)
return out
}
func contextInfoForMessage(m *waProto.Message) *waProto.ContextInfo {
if m == nil {
return nil
}
if ext := m.GetExtendedTextMessage(); ext != nil {
return ext.GetContextInfo()
}
if img := m.GetImageMessage(); img != nil {
return img.GetContextInfo()
}
if vid := m.GetVideoMessage(); vid != nil {
return vid.GetContextInfo()
}
if aud := m.GetAudioMessage(); aud != nil {
return aud.GetContextInfo()
}
if doc := m.GetDocumentMessage(); doc != nil {
return doc.GetContextInfo()
}
if sticker := m.GetStickerMessage(); sticker != nil {
return sticker.GetContextInfo()
}
if loc := m.GetLocationMessage(); loc != nil {
return loc.GetContextInfo()
}
if contact := m.GetContactMessage(); contact != nil {
return contact.GetContextInfo()
}
if contacts := m.GetContactsArrayMessage(); contacts != nil {
return contacts.GetContextInfo()
}
return nil
}
func displayTextForProto(m *waProto.Message) string {
if m == nil {
return ""
}
if img := m.GetImageMessage(); img != nil {
return "Sent image"
}
if vid := m.GetVideoMessage(); vid != nil {
if vid.GetGifPlayback() {
return "Sent gif"
}
return "Sent video"
}
if aud := m.GetAudioMessage(); aud != nil {
return "Sent audio"
}
if doc := m.GetDocumentMessage(); doc != nil {
return "Sent document"
}
if sticker := m.GetStickerMessage(); sticker != nil {
return "Sent sticker"
}
if loc := m.GetLocationMessage(); loc != nil {
return "Sent location"
}
if contact := m.GetContactMessage(); contact != nil {
return "Sent contact"
}
if contacts := m.GetContactsArrayMessage(); contacts != nil {
return "Sent contacts"
}
if text := strings.TrimSpace(m.GetConversation()); text != "" {
return text
}
if ext := m.GetExtendedTextMessage(); ext != nil {
if text := strings.TrimSpace(ext.GetText()); text != "" {
return text
}
}
return ""
}

View File

@ -74,3 +74,69 @@ func TestParseLiveMessageImageClonesBytes(t *testing.T) {
t.Fatalf("expected MediaKey to be cloned")
}
}
func TestParseLiveMessageReaction(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{
ReactionMessage: &waProto.ReactionMessage{
Text: proto.String("👍"),
Key: &waProto.MessageKey{ID: proto.String("orig")},
},
},
}
pm := ParseLiveMessage(ev)
if pm.ReactionEmoji != "👍" || pm.ReactionToID != "orig" {
t.Fatalf("unexpected reaction parse: %+v", pm)
}
}
func TestParseLiveMessageReply(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("reply text"),
ContextInfo: &waProto.ContextInfo{
StanzaID: proto.String("orig"),
QuotedMessage: &waProto.Message{
Conversation: proto.String("quoted"),
},
},
},
},
}
pm := ParseLiveMessage(ev)
if pm.ReplyToID != "orig" {
t.Fatalf("expected ReplyToID to be orig, got %q", pm.ReplyToID)
}
if pm.ReplyToDisplay != "quoted" {
t.Fatalf("expected ReplyToDisplay to be quoted, got %q", pm.ReplyToDisplay)
}
}