Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ce7b6de
Initial plan
Copilot May 11, 2026
c8ff445
Fix Windows WhatsApp session store DSN handling
Copilot May 11, 2026
4fbfa1c
Address review feedback for Windows DSN normalization
Copilot May 11, 2026
19b657d
Complete validation for Windows WhatsApp DSN fix
Copilot May 11, 2026
5f10eb5
Revert unintended go.mod changes
Copilot May 11, 2026
2effca4
Polish DSN helper naming for Windows path check
Copilot May 11, 2026
f8328a8
Rename Windows path helper for DSN clarity
Copilot May 11, 2026
785c4a6
Revert go.mod churn from local toolchain
Copilot May 11, 2026
9b2f745
Merge pull request #1 from chz160/copilot/fix-whatsapp-session-store-…
chz160 May 11, 2026
271780d
Skip iMessage local sync outside darwin
Copilot May 11, 2026
20c633c
Refactor darwin checks for iMessage sync gating
Copilot May 11, 2026
9de9cda
Revert unintended go.mod churn
Copilot May 11, 2026
83cf54c
Gate iMessage local sync to macOS in `serve` to eliminate unsupported…
Copilot May 11, 2026
ce82209
Merge pull request #2 from chz160/copilot/fix-local-platform-sync-war…
chz160 May 11, 2026
fc04169
Add sidebar Platforms button in web UI
Copilot May 11, 2026
a5d3376
Use existing banner entrypoint for Platforms dialog
Copilot May 11, 2026
712cbec
Merge pull request #3 from chz160/copilot/fix-platforms-dialog-access
chz160 May 11, 2026
1ff1489
fix: derive WhatsApp group name from participants when name is a raw JID
Copilot May 11, 2026
550575e
fix: address code review feedback - simplify fallback branch and use …
Copilot May 11, 2026
c8b7785
fix: address review - safer JID check, sorted/deduped names, tightene…
Copilot May 11, 2026
b6a59d8
Merge pull request #4 from chz160/copilot/debug-whatsapp-group-name-f…
chz160 May 11, 2026
14af673
Implement message transcript support
Copilot May 11, 2026
9c1f44e
Address transcript review feedback
Copilot May 11, 2026
41c9f9f
Polish transcript review follow-up
Copilot May 11, 2026
498fb84
Tidy transcript test assertion
Copilot May 11, 2026
867fd9f
Fix newest limit for conversation range queries
Copilot May 11, 2026
ff68759
Polish conversation newest limit fix
Copilot May 11, 2026
99fc57e
Harden transcript updates and query ordering
Copilot May 11, 2026
4a73b81
Polish transcript follow-up fixes
Copilot May 11, 2026
449a9f2
Clarify transcript handling in UpsertMessage
Copilot May 11, 2026
75218b2
Avoid no-op transcript writes
Copilot May 11, 2026
e0f5836
Simplify transcript no-op handling
Copilot May 11, 2026
30465f5
Validate transcript tool arguments
Copilot May 11, 2026
fee5c64
Merge pull request #5 from chz160/copilot/add-voice-message-transcripts
chz160 May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,11 @@ func RunServe(logger zerolog.Logger, args ...string) error {
}).ImportFromDB(store)
})
}
syncPlatform("imessage", "iMessage sync complete", func(store *db.Store) (*importer.ImportResult, error) {
return (&importer.IMessage{MyName: identityName}).ImportFromDB(store)
})
if iMessageSyncSupported() {
syncPlatform("imessage", "iMessage sync complete", func(store *db.Store) (*importer.ImportResult, error) {
return (&importer.IMessage{MyName: identityName}).ImportFromDB(store)
})
}
if changed {
events.PublishConversations()
events.PublishMessages("")
Expand Down Expand Up @@ -505,6 +507,14 @@ func macOSNotificationsEnabled(interactive bool) bool {
if !interactive {
return false
}
return isDarwin()
}

func iMessageSyncSupported() bool {
return isDarwin()
}

func isDarwin() bool {
return strings.EqualFold(runtimeGOOS(), "darwin")
}

Expand Down
17 changes: 17 additions & 0 deletions cmd/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,23 @@ func TestMacOSNotificationsEnabled(t *testing.T) {
})
}

func TestIMessageSyncSupported(t *testing.T) {
originalGOOS := runtimeGOOS
t.Cleanup(func() {
runtimeGOOS = originalGOOS
})

runtimeGOOS = func() string { return "darwin" }
if !iMessageSyncSupported() {
t.Fatal("expected iMessage sync to be supported on darwin")
}

runtimeGOOS = func() string { return "windows" }
if iMessageSyncSupported() {
t.Fatal("expected iMessage sync to be unsupported on windows")
}
}

func TestParseServeOptions(t *testing.T) {
t.Run("defaults to normal serve", func(t *testing.T) {
opts, err := parseServeOptions(nil)
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ require (
github.com/mdp/qrterminal/v3 v3.2.1
github.com/rs/zerolog v1.34.0
go.mau.fi/mautrix-gmessages v0.2601.0
go.mau.fi/util v0.9.6
go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4
golang.org/x/crypto v0.48.0
golang.org/x/net v0.50.0
golang.org/x/term v0.40.0
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.44.3
rsc.io/qr v0.2.0
)
Expand All @@ -34,13 +38,9 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.mau.fi/util v0.9.6 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
Expand Down
52 changes: 29 additions & 23 deletions internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,36 @@ type Store struct {
}

type Conversation struct {
ConversationID string
Name string
IsGroup bool
Participants string // JSON array
LastMessageTS int64
UnreadCount int
SourcePlatform string `json:"source_platform,omitempty"` // sms, gchat, imessage, whatsapp, signal, telegram
ConversationID string
Name string
IsGroup bool
Participants string // JSON array
LastMessageTS int64
UnreadCount int
SourcePlatform string `json:"source_platform,omitempty"` // sms, gchat, imessage, whatsapp, signal, telegram
NotificationMode string `json:"notification_mode,omitempty"` // all, mentions, muted
}

type Message struct {
MessageID string
ConversationID string
SenderName string
SenderNumber string
Body string
TimestampMS int64
Status string
IsFromMe bool
MentionsMe bool `json:"mentions_me,omitempty"`
MediaID string `json:",omitempty"`
MimeType string `json:",omitempty"`
DecryptionKey string `json:"-"` // hex-encoded, never exposed in API
Reactions string `json:",omitempty"` // JSON array of {emoji, count}
ReplyToID string `json:",omitempty"`
SourcePlatform string `json:"source_platform,omitempty"` // sms, gchat, imessage, whatsapp, signal, telegram
SourceID string `json:"source_id,omitempty"` // platform-specific original ID for dedup
MessageID string
ConversationID string
SenderName string
SenderNumber string
Body string
TimestampMS int64
Status string
IsFromMe bool
MentionsMe bool `json:"mentions_me,omitempty"`
MediaID string `json:",omitempty"`
MimeType string `json:",omitempty"`
DecryptionKey string `json:"-"` // hex-encoded, never exposed in API
Reactions string `json:",omitempty"` // JSON array of {emoji, count}
ReplyToID string `json:",omitempty"`
SourcePlatform string `json:"source_platform,omitempty"` // sms, gchat, imessage, whatsapp, signal, telegram
SourceID string `json:"source_id,omitempty"` // platform-specific original ID for dedup
Transcript string `json:"transcript,omitempty"`
TranscribedAtMS int64 `json:"transcribed_at_ms,omitempty"`
TranscriptModel string `json:"transcript_model,omitempty"`
}

type Contact struct {
Expand Down Expand Up @@ -280,6 +283,9 @@ func (s *Store) migrate() error {
// Multi-source support
"ALTER TABLE messages ADD COLUMN source_platform TEXT NOT NULL DEFAULT 'sms'",
"ALTER TABLE messages ADD COLUMN source_id TEXT NOT NULL DEFAULT ''",
"ALTER TABLE messages ADD COLUMN transcript TEXT NOT NULL DEFAULT ''",
"ALTER TABLE messages ADD COLUMN transcribed_at INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE messages ADD COLUMN transcript_model TEXT NOT NULL DEFAULT ''",
"ALTER TABLE conversations ADD COLUMN source_platform TEXT NOT NULL DEFAULT 'sms'",
"ALTER TABLE conversations ADD COLUMN notification_mode TEXT NOT NULL DEFAULT 'all'",
} {
Expand Down
85 changes: 74 additions & 11 deletions internal/db/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import (
"errors"
"fmt"
"strings"
"time"
)

// messageColumns is the canonical column list for SELECT queries on messages.
const messageColumns = `message_id, conversation_id, sender_name, sender_number, body, timestamp_ms, status, is_from_me, mentions_me, media_id, mime_type, decryption_key, reactions, reply_to_id, source_platform, source_id`
const messageColumns = `message_id, conversation_id, sender_name, sender_number, body, timestamp_ms, status, is_from_me, mentions_me, media_id, mime_type, decryption_key, reactions, reply_to_id, source_platform, source_id, transcript, transcribed_at, transcript_model`

var ErrMessageNotFound = errors.New("message not found")

func (s *Store) UpsertMessage(m *Message) error {
tx, err := s.db.Begin()
Expand Down Expand Up @@ -249,7 +252,7 @@ func (s *Store) GetMessageByID(messageID string) (*Message, error) {
FROM messages WHERE message_id = ?
`, messageID)
m := &Message{}
err := row.Scan(&m.MessageID, &m.ConversationID, &m.SenderName, &m.SenderNumber, &m.Body, &m.TimestampMS, &m.Status, &m.IsFromMe, &m.MentionsMe, &m.MediaID, &m.MimeType, &m.DecryptionKey, &m.Reactions, &m.ReplyToID, &m.SourcePlatform, &m.SourceID)
err := row.Scan(&m.MessageID, &m.ConversationID, &m.SenderName, &m.SenderNumber, &m.Body, &m.TimestampMS, &m.Status, &m.IsFromMe, &m.MentionsMe, &m.MediaID, &m.MimeType, &m.DecryptionKey, &m.Reactions, &m.ReplyToID, &m.SourcePlatform, &m.SourceID, &m.Transcript, &m.TranscribedAtMS, &m.TranscriptModel)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
Expand Down Expand Up @@ -360,10 +363,14 @@ func (s *Store) GetMessagesByConversations(conversationIDs []string, limit int)

rows, err := s.db.Query(`
SELECT `+messageColumns+`
FROM messages
WHERE conversation_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY timestamp_ms ASC
LIMIT ?
FROM (
SELECT `+messageColumns+`
FROM messages
WHERE conversation_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY timestamp_ms DESC, message_id DESC
LIMIT ?
)
ORDER BY timestamp_ms ASC, message_id ASC
`, args...)
if err != nil {
return nil, err
Expand Down Expand Up @@ -398,10 +405,14 @@ func (s *Store) GetMessagesByConversationsRange(conversationIDs []string, afterM

rows, err := s.db.Query(`
SELECT `+messageColumns+`
FROM messages
WHERE `+conditions+`
ORDER BY timestamp_ms ASC
LIMIT ?
FROM (
SELECT `+messageColumns+`
FROM messages
WHERE `+conditions+`
ORDER BY timestamp_ms DESC, message_id DESC
LIMIT ?
)
ORDER BY timestamp_ms ASC, message_id ASC
`, args...)
if err != nil {
return nil, err
Expand Down Expand Up @@ -494,14 +505,66 @@ func scanMessages(rows interface {
var msgs []*Message
for rows.Next() {
m := &Message{}
if err := rows.Scan(&m.MessageID, &m.ConversationID, &m.SenderName, &m.SenderNumber, &m.Body, &m.TimestampMS, &m.Status, &m.IsFromMe, &m.MentionsMe, &m.MediaID, &m.MimeType, &m.DecryptionKey, &m.Reactions, &m.ReplyToID, &m.SourcePlatform, &m.SourceID); err != nil {
if err := rows.Scan(&m.MessageID, &m.ConversationID, &m.SenderName, &m.SenderNumber, &m.Body, &m.TimestampMS, &m.Status, &m.IsFromMe, &m.MentionsMe, &m.MediaID, &m.MimeType, &m.DecryptionKey, &m.Reactions, &m.ReplyToID, &m.SourcePlatform, &m.SourceID, &m.Transcript, &m.TranscribedAtMS, &m.TranscriptModel); err != nil {
return nil, err
}
msgs = append(msgs, m)
}
return msgs, rows.Err()
}

// SetMessageTranscript writes a transcript for an existing message. It does
// not modify the message's body, media_id, or mime_type.
func (s *Store) SetMessageTranscript(messageID, transcript string, model *string) error {
if messageID == "" {
return fmt.Errorf("SetMessageTranscript: empty message_id")
}
msg, err := s.GetMessageByID(messageID)
if err != nil {
return fmt.Errorf("SetMessageTranscript: get message: %w", err)
}
if msg == nil {
return ErrMessageNotFound
}

nowMS := msg.TranscribedAtMS
modelToSave := msg.TranscriptModel
if model != nil {
modelToSave = *model
}
if transcript == "" {
if msg.Transcript == "" && msg.TranscribedAtMS == 0 && msg.TranscriptModel == "" {
return nil
}
nowMS = 0
modelToSave = ""
} else {
if msg.Transcript == transcript && msg.TranscriptModel == modelToSave && msg.TranscribedAtMS != 0 {
return nil
}
nowMS = time.Now().UnixMilli()
if nowMS <= msg.TranscribedAtMS {
nowMS = msg.TranscribedAtMS + 1
}
}
res, err := s.db.Exec(`
UPDATE messages
SET transcript = ?, transcribed_at = ?, transcript_model = ?
WHERE message_id = ?
`, transcript, nowMS, modelToSave, messageID)
if err != nil {
return fmt.Errorf("SetMessageTranscript: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("SetMessageTranscript: rows affected: %w", err)
}
if n == 0 {
return ErrMessageNotFound
}
return nil
}

func (s *Store) syncMessageSearchIndex(exec interface {
Exec(string, ...any) (sql.Result, error)
}, messageID, body string) error {
Expand Down
Loading