package main import ( "context" "database/sql" "encoding/binary" "encoding/json" "fmt" "io" "log" "log/slog" "math" "math/rand" "net/http" "os" "os/signal" "path/filepath" "reflect" "regexp" "strconv" "strings" "sync" "syscall" "time" "whatsapp-bridge/auth" "whatsapp-bridge/config" bridgelogger "whatsapp-bridge/logger" "whatsapp-bridge/wastate" "go.mau.fi/whatsmeow/proto/waCompanionReg" "go.mau.fi/whatsmeow/socket" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" "github.com/mdp/qrterminal" qrcode "github.com/skip2/go-qrcode" "bytes" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" "google.golang.org/protobuf/proto" ) type MessageInteraction struct { Timestamp time.Time `json:"timestamp"` Sender string `json:"sender"` Content string `json:"content"` IsFromMe bool `json:"is_from_me"` ChatJID string `json:"chat_jid"` ID string `json:"id"` ChatName string `json:"chat_name,omitempty"` MediaType string `json:"media_type,omitempty"` } type Chat struct { JID string `json:"jid"` Name string `json:"name,omitempty"` LastMessageTime time.Time `json:"last_message_time,omitempty"` LastMessage string `json:"last_message,omitempty"` LastSender string `json:"last_sender,omitempty"` LastIsFromMe bool `json:"last_is_from_me,omitempty"` } func (c *Chat) IsGroup() bool { return strings.HasSuffix(c.JID, "@g.us") } type Contact struct { PhoneNumber string `json:"phone_number"` Name string `json:"name,omitempty"` JID string `json:"jid"` } type MessageContext struct { Message MessageInteraction `json:"message"` Before []MessageInteraction `json:"before"` After []MessageInteraction `json:"after"` } type ListMessagesParams struct { After, Before string SenderPhoneNumber *string ChatJid *string Query *string Limit, Page int IncludeContext bool ContextBefore int ContextAfter int } type Message struct { Time time.Time Sender string Content string IsFromMe bool MediaType string Filename string } type MessageStore struct { db *sql.DB } var isPostgres = false func openDatabase(dbName string) (*sql.DB, error) { if val, ok := os.LookupEnv("IS_POSTGRES"); ok && strings.ToLower(val) == "true" { cfg, err := config.LoadConfig() if err != nil { return nil, fmt.Errorf("missing environment variable") } isPostgres = cfg.DB.IsPostgres connStr := fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", cfg.DB.User, cfg.DB.Pass, cfg.DB.Host, cfg.DB.Port, dbName) log.Println("Connecting to postgres") return sql.Open("postgres", connStr) } // Fallback to SQLite log.Println("Connecting to sqlite3") return sql.Open("sqlite3", "file:store/messages.db?_foreign_keys=on") } // validateMediaPath sanitize media path func validateMediaPath(mediaPath string) (string, error) { if mediaPath == "" { return "", fmt.Errorf("empty media path") } // Allowed media directory baseDir := "./media" absBaseDir, err := filepath.Abs(baseDir) if err != nil { return "", err } // Reject absolute paths if filepath.IsAbs(mediaPath) { return "", fmt.Errorf("absolute paths are not allowed") } // Clean traversal sequences cleanPath := filepath.Clean(mediaPath) fullPath := filepath.Join(absBaseDir, cleanPath) absPath, err := filepath.Abs(fullPath) if err != nil { return "", err } // Ensure resolved path stays inside media directory if !strings.HasPrefix(absPath, absBaseDir+string(os.PathSeparator)) && absPath != absBaseDir { return "", fmt.Errorf("path traversal detected") } return absPath, nil } // NewMessageStore Initialize message store func NewMessageStore() (*MessageStore, error) { if err := os.MkdirAll("store", 0755); err != nil { return nil, fmt.Errorf("failed to create store directory: %v", err) } db, err := openDatabase("whatsapp") if err != nil { return nil, fmt.Errorf("failed to open message database: %v", err) } val, ok := os.LookupEnv("IS_POSTGRES") var blobType string if ok && strings.ToLower(val) == "true" { blobType = "BYTEA" } else { blobType = "BLOB" } _, err = db.Exec(fmt.Sprintf(` CREATE TABLE IF NOT EXISTS chats ( jid TEXT PRIMARY KEY, name TEXT, last_message_time TIMESTAMP ); CREATE TABLE IF NOT EXISTS messages ( id TEXT, chat_jid TEXT, sender TEXT, content TEXT, timestamp TIMESTAMP, is_from_me BOOLEAN, media_type TEXT, filename TEXT, url TEXT, media_key %s, file_sha256 %s, file_enc_sha256 %s, file_length INTEGER, PRIMARY KEY (id, chat_jid), FOREIGN KEY (chat_jid) REFERENCES chats(jid) ); `, blobType, blobType, blobType)) if err != nil { db.Close() return nil, fmt.Errorf("failed to create tables: %v", err) } return &MessageStore{db: db}, nil } // Close the database connection func (store *MessageStore) Close() error { return store.db.Close() } // normalizeUserJID converts a LID JID (xxxx@lid) into a phone-number JID (xxxx@s.whatsapp.net) // using the whatsmeow LID mapping store. Non-LID JIDs are returned unchanged. func normalizeUserJID(client *whatsmeow.Client, jid types.JID) types.JID { if client == nil || client.Store == nil || client.Store.LIDs == nil { return jid } // Only normalize hidden-user server (@lid) if jid.Server != types.HiddenUserServer { return jid } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() pn, err := client.Store.LIDs.GetPNForLID(ctx, jid) if err != nil || pn.IsEmpty() { return jid } return pn } // migrateLIDChatsToPhoneJIDs merges chats stored under @lid // into their corresponding @s.whatsapp.net chats. // // This is idempotent and safe to run on every startup. // // Migration order: // // 1. Create/upsert PN chat // 2. Move messages to PN chat // 3. Delete leftover duplicate messages // 4. Delete old LID chat func migrateLIDChatsToPhoneJIDs( client *whatsmeow.Client, store *MessageStore, logger waLog.Logger, isPostgresDB bool, ) { if client == nil || store == nil || store.db == nil { return } db := store.db var query string arg := "%@" + types.HiddenUserServer if isPostgresDB { query = ` SELECT jid, name, last_message_time FROM chats WHERE jid LIKE $1 ` } else { query = ` SELECT jid, name, last_message_time FROM chats WHERE jid LIKE ? ` } rows, err := db.Query(query, arg) if err != nil { logger.Errorf("LID migration: failed listing chats: %v", err) return } defer rows.Close() type lidChat struct { JID string Name string LastMessageTime time.Time } var chats []lidChat for rows.Next() { var c lidChat if err := rows.Scan(&c.JID, &c.Name, &c.LastMessageTime); err != nil { logger.Warnf("LID migration: scan failed: %v", err) continue } chats = append(chats, c) } if len(chats) == 0 { return } logger.Infof("LID migration: found %d @lid chats", len(chats)) merged := 0 skipped := 0 for _, c := range chats { tx, err := db.Begin() if err != nil { logger.Warnf("LID migration: tx begin failed for %s: %v", c.JID, err) skipped++ continue } commit := false defer func() { if !commit { _ = tx.Rollback() } }() lidJID, parseErr := types.ParseJID(c.JID) if parseErr != nil { logger.Warnf("LID migration: invalid jid %s: %v", c.JID, parseErr) _ = tx.Rollback() skipped++ continue } pnJID := normalizeUserJID(client, lidJID) if pnJID.Server != types.DefaultUserServer { _ = tx.Rollback() skipped++ continue } pnStr := pnJID.String() var upsertQuery string if isPostgresDB { upsertQuery = ` INSERT INTO chats (jid, name, last_message_time) VALUES ($1, $2, $3) ON CONFLICT (jid) DO UPDATE SET name = COALESCE(NULLIF(chats.name, ''), EXCLUDED.name), last_message_time = GREATEST( chats.last_message_time, EXCLUDED.last_message_time ) ` } else { upsertQuery = ` INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = COALESCE(NULLIF(chats.name, ''), excluded.name), last_message_time = MAX( chats.last_message_time, excluded.last_message_time ) ` } if _, err = tx.Exec(upsertQuery, pnStr, c.Name, c.LastMessageTime); err != nil { logger.Warnf("LID migration: upsert failed %s -> %s: %v", c.JID, pnStr, err) _ = tx.Rollback() skipped++ continue } var moveMessagesQuery string if isPostgresDB { moveMessagesQuery = ` UPDATE messages SET chat_jid = $1 WHERE chat_jid = $2 ` } else { moveMessagesQuery = ` UPDATE messages SET chat_jid = ? WHERE chat_jid = ? ` } if _, err = tx.Exec(moveMessagesQuery, pnStr, c.JID); err != nil { logger.Warnf("LID migration: move messages failed %s -> %s: %v", c.JID, pnStr, err) _ = tx.Rollback() skipped++ continue } var deleteChatQuery string if isPostgresDB { deleteChatQuery = `DELETE FROM chats WHERE jid = $1` } else { deleteChatQuery = `DELETE FROM chats WHERE jid = ?` } if _, err = tx.Exec(deleteChatQuery, c.JID); err != nil { logger.Warnf("LID migration: delete old chat failed %s: %v", c.JID, err) _ = tx.Rollback() skipped++ continue } if err = tx.Commit(); err != nil { logger.Warnf("LID migration: commit failed %s: %v", c.JID, err) _ = tx.Rollback() skipped++ continue } commit = true logger.Infof("LID migration: merged %s -> %s", c.JID, pnStr) merged++ } logger.Infof( "LID migration complete: %d merged, %d skipped", merged, skipped, ) } // StoreChat Store a chat in the database func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error { if isPostgres { _, err := store.db.Exec( `INSERT INTO chats (jid, name, last_message_time) VALUES ($1, $2, $3) ON CONFLICT (jid) DO UPDATE SET name = EXCLUDED.name, last_message_time = EXCLUDED.last_message_time`, jid, name, lastMessageTime, ) return err } _, err := store.db.Exec( "INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)", jid, name, lastMessageTime, ) return err } // StoreMessage Store a message in the database func (store *MessageStore) StoreMessage(id, chatJID, sender, content string, timestamp time.Time, isFromMe bool, mediaType, filename, url string, mediaKey, fileSHA256, fileEncSHA256 []byte, fileLength uint64) error { if content == "" && mediaType == "" { return nil } if !isPostgres { _, err := store.db.Exec( `INSERT OR REPLACE INTO messages (id, chat_jid, sender, content, timestamp, is_from_me, media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, chatJID, sender, content, timestamp, isFromMe, mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, ) return err } _, err := store.db.Exec( `INSERT INTO messages (id, chat_jid, sender, content, timestamp, is_from_me, media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT(id, chat_jid) DO UPDATE SET chat_jid = EXCLUDED.chat_jid, sender = EXCLUDED.sender, content = EXCLUDED.content, timestamp = EXCLUDED.timestamp, is_from_me = EXCLUDED.is_from_me, media_type = EXCLUDED.media_type, filename = EXCLUDED.filename, url = EXCLUDED.url, media_key = EXCLUDED.media_key, file_sha256 = EXCLUDED.file_sha256, file_enc_sha256 = EXCLUDED.file_enc_sha256, file_length = EXCLUDED.file_length`, id, chatJID, sender, content, timestamp, isFromMe, mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, ) return err } // GetMessages Get messages from a chat func (store *MessageStore) GetMessages(chatJID string, limit int) ([]Message, error) { var rows *sql.Rows var err error if isPostgres { rows, err = store.db.Query( `SELECT sender, content, timestamp, is_from_me, media_type, filename FROM messages WHERE chat_jid = $1 ORDER BY timestamp DESC LIMIT $2`, chatJID, limit, ) } else { rows, err = store.db.Query( "SELECT sender, content, timestamp, is_from_me, media_type, filename FROM messages WHERE chat_jid = ? ORDER BY timestamp DESC LIMIT ?", chatJID, limit, ) } if err != nil { return nil, err } defer rows.Close() var messages []Message for rows.Next() { var msg Message var timestamp time.Time err := rows.Scan(&msg.Sender, &msg.Content, ×tamp, &msg.IsFromMe, &msg.MediaType, &msg.Filename) if err != nil { return nil, err } msg.Time = timestamp messages = append(messages, msg) } return messages, nil } // GetChats Get all chats func (store *MessageStore) GetChats() (map[string]time.Time, error) { rows, err := store.db.Query("SELECT jid, last_message_time FROM chats ORDER BY last_message_time DESC") if err != nil { return nil, err } defer rows.Close() chats := make(map[string]time.Time) for rows.Next() { var jid string var lastMessageTime time.Time err := rows.Scan(&jid, &lastMessageTime) if err != nil { return nil, err } chats[jid] = lastMessageTime } return chats, nil } // Extract text content from a message func extractTextContent(msg *waE2E.Message) string { if msg == nil { return "" } // Try to get text content if text := msg.GetConversation(); text != "" { return text } else if extendedText := msg.GetExtendedTextMessage(); extendedText != nil { return extendedText.GetText() } return "" } // SendMessageResponse represents the response for the send message API type SendMessageResponse struct { Success bool `json:"success"` Message string `json:"message"` } // SendMessageRequest represents the request body for the send message API type SendMessageRequest struct { Recipient string `json:"recipient"` Message string `json:"message"` MediaPath string `json:"media_path,omitempty"` } var clientVersionRegex = regexp.MustCompile(`"client_revision":(\d+),`) func CustomGetLatestVersion(ctx context.Context, httpClient *http.Client) (*store.WAVersionContainer, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, socket.Origin, nil) if err != nil { return nil, fmt.Errorf("failed to prepare request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36") req.Header.Set("Sec-Fetch-Dest", "document") req.Header.Set("Sec-Fetch-Mode", "navigate") req.Header.Set("Sec-Fetch-Site", "none") req.Header.Set("Sec-Fetch-User", "?1") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") req.Header.Set("Accept-Language", "en-US,en;q=0.9") if httpClient == nil { httpClient = http.DefaultClient } resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } data, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } else if resp.StatusCode != 200 { return nil, fmt.Errorf("unexpected response with status %d: %s", resp.StatusCode, data) } else if match := clientVersionRegex.FindSubmatch(data); len(match) == 0 { return nil, fmt.Errorf("version number not found") } else if parsedVer, err := strconv.ParseInt(string(match[1]), 10, 64); err != nil { return nil, fmt.Errorf("failed to parse version number: %w", err) } else { return &store.WAVersionContainer{2, 3000, uint32(parsedVer)}, nil } } // Function to send a WhatsApp message func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message string, mediaPath string) (bool, string) { if !client.IsConnected() { return false, "Not connected to WhatsApp" } var recipientJID types.JID var err error isJID := strings.Contains(recipient, "@") if isJID { recipientJID, err = types.ParseJID(recipient) if err != nil { return false, fmt.Sprintf("Error parsing JID: %v", err) } } else { recipientJID = types.JID{ User: recipient, Server: "s.whatsapp.net", // For personal chats } } msg := &waE2E.Message{} if mediaPath != "" { validatedPath, err := validateMediaPath(mediaPath) if err != nil { return false, fmt.Sprintf("Invalid media path: %v", err) } mediaData, err := os.ReadFile(validatedPath) if err != nil { return false, fmt.Sprintf("Error reading media file: %v", err) } fileExt := strings.ToLower(mediaPath[strings.LastIndex(mediaPath, ".")+1:]) var mediaType whatsmeow.MediaType var mimeType string switch fileExt { case "jpg", "jpeg": mediaType = whatsmeow.MediaImage mimeType = "image/jpeg" case "png": mediaType = whatsmeow.MediaImage mimeType = "image/png" case "gif": mediaType = whatsmeow.MediaImage mimeType = "image/gif" case "webp": mediaType = whatsmeow.MediaImage mimeType = "image/webp" case "ogg": mediaType = whatsmeow.MediaAudio mimeType = "audio/ogg; codecs=opus" case "mp4": mediaType = whatsmeow.MediaVideo mimeType = "video/mp4" case "avi": mediaType = whatsmeow.MediaVideo mimeType = "video/avi" case "mov": mediaType = whatsmeow.MediaVideo mimeType = "video/quicktime" default: mediaType = whatsmeow.MediaDocument mimeType = "application/octet-stream" } resp, err := client.Upload(context.Background(), mediaData, mediaType) if err != nil { return false, fmt.Sprintf("Error uploading media: %v", err) } slog.Info("media uploaded", "response", resp) switch mediaType { case whatsmeow.MediaImage: msg.ImageMessage = &waE2E.ImageMessage{ Caption: proto.String(message), Mimetype: proto.String(mimeType), URL: &resp.URL, DirectPath: &resp.DirectPath, MediaKey: resp.MediaKey, FileEncSHA256: resp.FileEncSHA256, FileSHA256: resp.FileSHA256, FileLength: &resp.FileLength, } case whatsmeow.MediaAudio: var seconds uint32 = 30 var waveform []byte = nil if strings.Contains(mimeType, "ogg") { analyzedSeconds, analyzedWaveform, err := analyzeOggOpus(mediaData) if err == nil { seconds = analyzedSeconds waveform = analyzedWaveform } else { return false, fmt.Sprintf("Failed to analyze Ogg Opus file: %v", err) } } else { slog.Warn("not an Ogg Opus file", "mime_type", mimeType) } msg.AudioMessage = &waE2E.AudioMessage{ Mimetype: proto.String(mimeType), URL: &resp.URL, DirectPath: &resp.DirectPath, MediaKey: resp.MediaKey, FileEncSHA256: resp.FileEncSHA256, FileSHA256: resp.FileSHA256, FileLength: &resp.FileLength, Seconds: proto.Uint32(seconds), PTT: proto.Bool(true), Waveform: waveform, } case whatsmeow.MediaVideo: msg.VideoMessage = &waE2E.VideoMessage{ Caption: proto.String(message), Mimetype: proto.String(mimeType), URL: &resp.URL, DirectPath: &resp.DirectPath, MediaKey: resp.MediaKey, FileEncSHA256: resp.FileEncSHA256, FileSHA256: resp.FileSHA256, FileLength: &resp.FileLength, } case whatsmeow.MediaDocument: msg.DocumentMessage = &waE2E.DocumentMessage{ Title: proto.String(mediaPath[strings.LastIndex(mediaPath, "/")+1:]), Caption: proto.String(message), Mimetype: proto.String(mimeType), URL: &resp.URL, DirectPath: &resp.DirectPath, MediaKey: resp.MediaKey, FileEncSHA256: resp.FileEncSHA256, FileSHA256: resp.FileSHA256, FileLength: &resp.FileLength, } } } else { msg.Conversation = proto.String(message) } _, err = client.SendMessage(context.Background(), recipientJID, msg) if err != nil { return false, fmt.Sprintf("Error sending message: %v", err) } return true, fmt.Sprintf("Message sent to %s", recipient) } // Extract media info from a message func extractMediaInfo(msg *waE2E.Message) (mediaType string, filename string, url string, mediaKey []byte, fileSHA256 []byte, fileEncSHA256 []byte, fileLength uint64) { if msg == nil { return "", "", "", nil, nil, nil, 0 } if img := msg.GetImageMessage(); img != nil { return "image", "image_" + time.Now().Format("20060102_150405") + ".jpg", img.GetURL(), img.GetMediaKey(), img.GetFileSHA256(), img.GetFileEncSHA256(), img.GetFileLength() } if vid := msg.GetVideoMessage(); vid != nil { return "video", "video_" + time.Now().Format("20060102_150405") + ".mp4", vid.GetURL(), vid.GetMediaKey(), vid.GetFileSHA256(), vid.GetFileEncSHA256(), vid.GetFileLength() } if aud := msg.GetAudioMessage(); aud != nil { return "audio", "audio_" + time.Now().Format("20060102_150405") + ".ogg", aud.GetURL(), aud.GetMediaKey(), aud.GetFileSHA256(), aud.GetFileEncSHA256(), aud.GetFileLength() } if doc := msg.GetDocumentMessage(); doc != nil { filename := doc.GetFileName() if filename == "" { filename = "document_" + time.Now().Format("20060102_150405") } return "document", filename, doc.GetURL(), doc.GetMediaKey(), doc.GetFileSHA256(), doc.GetFileEncSHA256(), doc.GetFileLength() } return "", "", "", nil, nil, nil, 0 } // Handle regular incoming messages with media support func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *events.Message, logger waLog.Logger) { go func() { cfg, err := config.LoadConfig() if err != nil { return } defer func() { if r := recover(); r != nil { log.Println("Recovered in webhook goroutine:", r) } }() if cfg.WebhookUrl == "" { return } content := extractTextContent(msg.Message) if content == "" { return } payload := map[string]interface{}{ "chat_jid": msg.Info.Chat.String(), "sender": msg.Info.Sender.User, "content": extractTextContent(msg.Message), "is_from_me": msg.Info.IsFromMe, "timestamp": msg.Info.Timestamp.String(), "push_name": msg.Info.PushName, "is_group": strings.Contains(msg.Info.Chat.String(), "@g.us"), "message_id": msg.Info.ID, } jsonData, err := json.Marshal(payload) if err != nil { log.Println("Webhook marshal error:", err) return } resp, err := http.Post( cfg.WebhookUrl, "application/json", bytes.NewBuffer(jsonData), ) if err != nil { log.Println("Webhook POST error:", err) return } defer resp.Body.Close() }() chatJID := normalizeUserJID(client, msg.Info.Chat).String() sender := normalizeUserJID(client, msg.Info.Sender).User name := GetChatName(client, messageStore, msg.Info.Chat, chatJID, nil, sender, logger) err := messageStore.StoreChat(chatJID, name, msg.Info.Timestamp) if err != nil { logger.Warnf("Failed to store chat: %v", err) } content := extractTextContent(msg.Message) mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength := extractMediaInfo(msg.Message) if content == "" && mediaType == "" { return } err = messageStore.StoreMessage( msg.Info.ID, chatJID, sender, content, msg.Info.Timestamp, msg.Info.IsFromMe, mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, ) if err != nil { logger.Warnf("Failed to store message: %v", err) } else { timestamp := msg.Info.Timestamp.Format("2006-01-02 15:04:05") direction := "←" if msg.Info.IsFromMe { direction = "→" } if mediaType != "" { slog.Info("message", "ts", timestamp, "direction", direction, "sender", sender, "media_type", mediaType, "filename", filename, "content", content) } else if content != "" { slog.Info("message", "ts", timestamp, "direction", direction, "sender", sender, "content", content) } } } // DownloadMediaRequest represents the request body for the download media API type DownloadMediaRequest struct { MessageID string `json:"message_id"` ChatJID string `json:"chat_jid"` } // DownloadMediaResponse represents the response for the download media API type DownloadMediaResponse struct { Success bool `json:"success"` Message string `json:"message"` Filename string `json:"filename,omitempty"` Path string `json:"path,omitempty"` } // StoreMediaInfo Store additional media info in the database func (store *MessageStore) StoreMediaInfo(id, chatJID, url string, mediaKey, fileSHA256, fileEncSHA256 []byte, fileLength uint64) error { if isPostgres { _, err := store.db.Exec( `UPDATE messages SET url = $1, media_key = $2, file_sha256 = $3, file_enc_sha256 = $4, file_length = $5 WHERE id = $6 AND chat_jid = $7`, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, id, chatJID, ) return err } _, err := store.db.Exec( "UPDATE messages SET url = ?, media_key = ?, file_sha256 = ?, file_enc_sha256 = ?, file_length = ? WHERE id = ? AND chat_jid = ?", url, mediaKey, fileSHA256, fileEncSHA256, fileLength, id, chatJID, ) return err } // GetMediaInfo Get media info from the database func (store *MessageStore) GetMediaInfo(id, chatJID string) (string, string, string, []byte, []byte, []byte, uint64, error) { var mediaType, filename, url string var mediaKey, fileSHA256, fileEncSHA256 []byte var fileLength uint64 var err error if isPostgres { err = store.db.QueryRow( `SELECT media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length FROM messages WHERE id = $1 AND chat_jid = $2`, id, chatJID, ).Scan(&mediaType, &filename, &url, &mediaKey, &fileSHA256, &fileEncSHA256, &fileLength) } else { err = store.db.QueryRow( "SELECT media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length FROM messages WHERE id = ? AND chat_jid = ?", id, chatJID, ).Scan(&mediaType, &filename, &url, &mediaKey, &fileSHA256, &fileEncSHA256, &fileLength) } return mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, err } // MediaDownloader implements the whatsmeow.DownloadableMessage interface type MediaDownloader struct { URL string DirectPath string MediaKey []byte FileLength uint64 FileSHA256 []byte FileEncSHA256 []byte MediaType whatsmeow.MediaType } // GetDirectPath implements the DownloadableMessage interface func (d *MediaDownloader) GetDirectPath() string { return d.DirectPath } // GetURL implements the DownloadableMessage interface func (d *MediaDownloader) GetURL() string { return d.URL } // GetMediaKey implements the DownloadableMessage interface func (d *MediaDownloader) GetMediaKey() []byte { return d.MediaKey } // GetFileLength implements the DownloadableMessage interface func (d *MediaDownloader) GetFileLength() uint64 { return d.FileLength } // GetFileSHA256 implements the DownloadableMessage interface func (d *MediaDownloader) GetFileSHA256() []byte { return d.FileSHA256 } // GetFileEncSHA256 implements the DownloadableMessage interface func (d *MediaDownloader) GetFileEncSHA256() []byte { return d.FileEncSHA256 } // GetMediaType implements the DownloadableMessage interface func (d *MediaDownloader) GetMediaType() whatsmeow.MediaType { return d.MediaType } // Function to download media from a message func downloadMedia(client *whatsmeow.Client, messageStore *MessageStore, messageID, chatJID string) (bool, string, string, string, error) { var mediaType, filename, url string var mediaKey, fileSHA256, fileEncSHA256 []byte var fileLength uint64 var err error chatDir := fmt.Sprintf("store/%s", strings.ReplaceAll(chatJID, ":", "_")) localPath := "" mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, err = messageStore.GetMediaInfo(messageID, chatJID) if err != nil { if isPostgres { err = messageStore.db.QueryRow( `SELECT media_type, filename FROM messages WHERE id = $1 AND chat_jid = $2`, messageID, chatJID, ).Scan(&mediaType, &filename) } else { err = messageStore.db.QueryRow( "SELECT media_type, filename FROM messages WHERE id = ? AND chat_jid = ?", messageID, chatJID, ).Scan(&mediaType, &filename) } if err != nil { return false, "", "", "", fmt.Errorf("failed to find message: %v", err) } } if mediaType == "" { return false, "", "", "", fmt.Errorf("not a media message") } if err := os.MkdirAll(chatDir, 0755); err != nil { return false, "", "", "", fmt.Errorf("failed to create chat directory: %v", err) } localPath = fmt.Sprintf("%s/%s", chatDir, filename) absPath, err := filepath.Abs(localPath) if err != nil { return false, "", "", "", fmt.Errorf("failed to get absolute path: %v", err) } if _, err := os.Stat(localPath); err == nil { return true, mediaType, filename, absPath, nil } if url == "" || len(mediaKey) == 0 || len(fileSHA256) == 0 || len(fileEncSHA256) == 0 || fileLength == 0 { return false, "", "", "", fmt.Errorf("incomplete media information for download") } slog.Info("attempting to download media", "message_id", messageID, "chat_jid", chatJID) directPath := extractDirectPathFromURL(url) var waMediaType whatsmeow.MediaType switch mediaType { case "image": waMediaType = whatsmeow.MediaImage case "video": waMediaType = whatsmeow.MediaVideo case "audio": waMediaType = whatsmeow.MediaAudio case "document": waMediaType = whatsmeow.MediaDocument default: return false, "", "", "", fmt.Errorf("unsupported media type: %s", mediaType) } downloader := &MediaDownloader{ URL: url, DirectPath: directPath, MediaKey: mediaKey, FileLength: fileLength, FileSHA256: fileSHA256, FileEncSHA256: fileEncSHA256, MediaType: waMediaType, } mediaData, err := client.Download(context.Background(), downloader) if err != nil { return false, "", "", "", fmt.Errorf("failed to download media: %v", err) } if err := os.WriteFile(localPath, mediaData, 0644); err != nil { return false, "", "", "", fmt.Errorf("failed to save media file: %v", err) } slog.Info("successfully downloaded media", "media_type", mediaType, "path", absPath, "bytes", len(mediaData)) return true, mediaType, filename, absPath, nil } func extractDirectPathFromURL(url string) string { // The direct path is typically in the URL, we need to extract it // Example URL: https://mmg.whatsapp.net/v/t62.7118-24/13812002_698058036224062_3424455886509161511_n.enc?ccb=11-4&oh=... parts := strings.SplitN(url, ".net/", 2) if len(parts) < 2 { return url // Return original URL if parsing fails } pathPart := parts[1] pathPart = strings.SplitN(pathPart, "?", 2)[0] return "/" + pathPart } // Start a REST API server to expose the WhatsApp client functionality func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, cfg *config.Config, state *wastate.State) { apiMux := http.NewServeMux() // Send message apiMux.HandleFunc("/send", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req SendMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request format", http.StatusBadRequest) return } if req.Recipient == "" { http.Error(w, "Recipient is required", http.StatusBadRequest) return } if req.Message == "" && req.MediaPath == "" { http.Error(w, "Message or media path is required", http.StatusBadRequest) return } slog.Info("received request to send message", "message", req.Message, "media_path", req.MediaPath) success, message := sendWhatsAppMessage(client, req.Recipient, req.Message, req.MediaPath) slog.Info("message sent", "success", success, "message", message) w.Header().Set("Content-Type", "application/json") if !success { w.WriteHeader(http.StatusInternalServerError) } json.NewEncoder(w).Encode(SendMessageResponse{ Success: success, Message: message, }) }) // Handler for downloading media apiMux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req DownloadMediaRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request format", http.StatusBadRequest) return } if req.MessageID == "" || req.ChatJID == "" { http.Error(w, "Message ID and Chat JID are required", http.StatusBadRequest) return } success, mediaType, filename, path, err := downloadMedia(client, messageStore, req.MessageID, req.ChatJID) w.Header().Set("Content-Type", "application/json") if !success || err != nil { errMsg := "Unknown error" if err != nil { errMsg = err.Error() } w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(DownloadMediaResponse{ Success: false, Message: fmt.Sprintf("Failed to download media: %s", errMsg), }) return } json.NewEncoder(w).Encode(DownloadMediaResponse{ Success: true, Message: fmt.Sprintf("Successfully downloaded %s media", mediaType), Filename: filename, Path: path, }) }) // List recent chats apiMux.HandleFunc("/chats", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } q := r.URL.Query().Get("q") limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) page, _ := strconv.Atoi(r.URL.Query().Get("page")) sortBy := r.URL.Query().Get("sort") if limit < 1 { limit = 30 } if page < 0 { page = 0 } var queryPtr *string if q != "" { queryPtr = &q } chats, err := messageStore.ListChats(queryPtr, limit, page, true, sortBy) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "chats": chats, "count": len(chats), }) }) // Get single chat apiMux.HandleFunc("/chats/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } jid := strings.TrimPrefix(r.URL.Path, "/chats/") if jid == "" { http.Error(w, "Missing chat JID", http.StatusBadRequest) return } chat, err := messageStore.GetChat(jid, true) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } if chat == nil { http.Error(w, "Chat not found", http.StatusNotFound) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "chat": chat, }) }) // List messages (very flexible) apiMux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } params := ListMessagesParams{ Limit: 50, Page: 0, IncludeContext: false, ContextBefore: 5, ContextAfter: 5, } q := r.URL.Query() if after := q.Get("after"); after != "" { params.After = after } if before := q.Get("before"); before != "" { params.Before = before } if chat := q.Get("chat"); chat != "" { params.ChatJid = &chat } if sender := q.Get("sender"); sender != "" { params.SenderPhoneNumber = &sender } if search := q.Get("search"); search != "" { params.Query = &search } if lim := q.Get("limit"); lim != "" { if v, err := strconv.Atoi(lim); err == nil && v > 0 { params.Limit = v } } if pg := q.Get("page"); pg != "" { if v, err := strconv.Atoi(pg); err == nil && v >= 0 { params.Page = v } } if ctx := q.Get("context"); ctx == "true" { params.IncludeContext = true } result, err := messageStore.ListMessages(params) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "result": result, }) }) // Get message + context apiMux.HandleFunc("/messages/context/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } messageID := strings.TrimPrefix(r.URL.Path, "/messages/context/") if messageID == "" { http.Error(w, "Missing message ID", http.StatusBadRequest) return } before, _ := strconv.Atoi(r.URL.Query().Get("before")) after, _ := strconv.Atoi(r.URL.Query().Get("after")) if before == 0 { before = 6 } if after == 0 { after = 6 } ctx, err := messageStore.GetMessageContext(messageID, before, after) if err != nil { respondError(w, http.StatusNotFound, err.Error()) return } respondJSON(w, http.StatusOK, ctx) }) // Search contacts apiMux.HandleFunc("/contacts/search", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } query := r.URL.Query().Get("q") if query == "" { http.Error(w, "Missing search query (?q=...)", http.StatusBadRequest) return } contacts, err := messageStore.SearchContacts(query) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "contacts": contacts, }) }) // GET /api/direct-contacts/:phone/chat // Find the 1:1 (direct) chat for a given phone number apiMux.HandleFunc("/direct-contacts/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } path := strings.TrimPrefix(r.URL.Path, "/direct-contacts/") parts := strings.Split(path, "/") if len(parts) < 2 || parts[1] != "chat" { http.Error(w, "Invalid path. Use /api/direct-contacts/{phone}/chat", http.StatusBadRequest) return } phone := parts[0] if phone == "" { http.Error(w, "Phone number is required", http.StatusBadRequest) return } chat, err := messageStore.GetDirectChatByContact(phone) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } if chat == nil { respondJSON(w, http.StatusNotFound, map[string]string{ "error": "No direct chat found for this phone number", }) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "chat": chat, }) }) // GET /api/contacts/:jid/chats // List all chats where this contact (by JID) appears as sender or in group apiMux.HandleFunc("/contacts/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } path := strings.TrimPrefix(r.URL.Path, "/contacts/") parts := strings.Split(path, "/") if len(parts) < 2 || parts[1] != "chats" { // Skip if not this endpoint (previous handler already took /chat) return } jid := parts[0] if jid == "" { http.Error(w, "JID is required", http.StatusBadRequest) return } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) page, _ := strconv.Atoi(r.URL.Query().Get("page")) if limit <= 0 { limit = 20 } if page < 0 { page = 0 } chats, err := messageStore.GetContactChats(jid, limit, page) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "chats": chats, "count": len(chats), }) }) apiMux.HandleFunc("/auth/status", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } respondJSON(w, http.StatusOK, map[string]any{ "connected": state.Connected(), "logged_in": state.LoggedIn(), "pairing_required": state.PairingRequired(), "wa_version": state.WAVersion(), }) }) apiMux.HandleFunc("/auth/pairing-qr", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } png := state.PairingQRPNG() if png == nil { http.Error(w, "no pairing QR available; client is logged in or has not started pairing yet", http.StatusGone) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) _, _ = w.Write(png) }) // Authentication protected := auth.JwtAuthMiddleware(cfg, apiMux) http.Handle("/api/", http.StripPrefix("/api", protected)) http.Handle("/auth/login", auth.LoginHandler(cfg)) serverAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) slog.Info("starting REST API server", "addr", serverAddr) go func() { if err := http.ListenAndServe(serverAddr, nil); err != nil { slog.Error("rest api server error", "err", err) } }() } func respondJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func respondError(w http.ResponseWriter, status int, msg string) { respondJSON(w, status, map[string]string{ "error": msg, }) } // GetChatName determines the appropriate name for a chat based on JID and other info func GetChatName(client *whatsmeow.Client, messageStore *MessageStore, jid types.JID, chatJID string, conversation interface{}, sender string, logger waLog.Logger) string { var existingName string err := messageStore.db.QueryRow("SELECT name FROM chats WHERE jid = ?", chatJID).Scan(&existingName) if err == nil && existingName != "" { logger.Infof("Using existing chat name for %s: %s", chatJID, existingName) return existingName } var name string if jid.Server == "g.us" { logger.Infof("Getting name for group: %s", chatJID) if conversation != nil { var displayName, convName *string v := reflect.ValueOf(conversation) if v.Kind() == reflect.Ptr && !v.IsNil() { v = v.Elem() if displayNameField := v.FieldByName("DisplayName"); displayNameField.IsValid() && displayNameField.Kind() == reflect.Ptr && !displayNameField.IsNil() { dn := displayNameField.Elem().String() displayName = &dn } if nameField := v.FieldByName("Name"); nameField.IsValid() && nameField.Kind() == reflect.Ptr && !nameField.IsNil() { n := nameField.Elem().String() convName = &n } } if displayName != nil && *displayName != "" { name = *displayName } else if convName != nil && *convName != "" { name = *convName } } if name == "" { groupInfo, err := client.GetGroupInfo(context.Background(), jid) if err == nil && groupInfo.Name != "" { name = groupInfo.Name } else { name = fmt.Sprintf("Group %s", jid.User) } } logger.Infof("Using group name: %s", name) } else { logger.Infof("Getting name for contact: %s", chatJID) contact, err := client.Store.Contacts.GetContact(context.Background(), jid) if err == nil { switch { case contact.FullName != "": name = contact.FullName case contact.PushName != "": name = contact.PushName case contact.FirstName != "": name = contact.FirstName } } if name == "" && sender != "" { name = sender } if name == "" { name = jid.User } logger.Infof("Using contact name: %s", name) } return name } // Handle history sync events func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, historySync *events.HistorySync, logger waLog.Logger) { slog.Info("received history sync event", "conversations", len(historySync.Data.Conversations)) syncedCount := 0 for _, conversation := range historySync.Data.Conversations { if conversation.ID == nil { continue } chatJID := *conversation.ID jid, err := types.ParseJID(chatJID) if err != nil { logger.Warnf("Failed to parse JID %s: %v", chatJID, err) continue } jid = normalizeUserJID(client, jid) chatJID = jid.String() name := GetChatName(client, messageStore, jid, chatJID, conversation, "", logger) messages := conversation.Messages if len(messages) > 0 { latestMsg := messages[0] if latestMsg == nil || latestMsg.Message == nil { continue } timestamp := time.Time{} if ts := latestMsg.Message.GetMessageTimestamp(); ts != 0 { timestamp = time.Unix(int64(ts), 0) } else { continue } messageStore.StoreChat(chatJID, name, timestamp) for _, msg := range messages { if msg == nil || msg.Message == nil { continue } var content string if msg.Message.Message != nil { if conv := msg.Message.Message.GetConversation(); conv != "" { content = conv } else if ext := msg.Message.Message.GetExtendedTextMessage(); ext != nil { content = ext.GetText() } } var mediaType, filename, url string var mediaKey, fileSHA256, fileEncSHA256 []byte var fileLength uint64 if msg.Message.Message != nil { mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength = extractMediaInfo(msg.Message.Message) } logger.Infof("Message content: %v, Media Type: %v", content, mediaType) if content == "" && mediaType == "" { continue } var sender string isFromMe := false if msg.Message.Key != nil { if msg.Message.Key.FromMe != nil { isFromMe = *msg.Message.Key.FromMe } if !isFromMe && msg.Message.Key.Participant != nil && *msg.Message.Key.Participant != "" { if pJid, err := types.ParseJID(*msg.Message.Key.Participant); err == nil { sender = normalizeUserJID(client, pJid).User } else { sender = *msg.Message.Key.Participant } } else if isFromMe { sender = client.Store.ID.User } else { sender = jid.User } } else { sender = jid.User } msgID := "" if msg.Message.Key != nil && msg.Message.Key.ID != nil { msgID = *msg.Message.Key.ID } timestamp := time.Time{} if ts := msg.Message.GetMessageTimestamp(); ts != 0 { timestamp = time.Unix(int64(ts), 0) } else { continue } err = messageStore.StoreMessage( msgID, chatJID, sender, content, timestamp, isFromMe, mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, ) if err != nil { logger.Warnf("Failed to store history message: %v", err) } else { syncedCount++ if mediaType != "" { logger.Infof("Stored message: [%s] %s -> %s: [%s: %s] %s", timestamp.Format("2006-01-02 15:04:05"), sender, chatJID, mediaType, filename, content) } else { logger.Infof("Stored message: [%s] %s -> %s: %s", timestamp.Format("2006-01-02 15:04:05"), sender, chatJID, content) } } } } } slog.Info("history sync complete", "stored_messages", syncedCount) } // Request history sync from the server func requestHistorySync(client *whatsmeow.Client) { if client == nil { slog.Error("client is not initialized, cannot request history sync") return } if !client.IsConnected() { slog.Warn("client is not connected to whatsapp") return } if client.Store.ID == nil { slog.Warn("client is not logged in, please scan the qr code") return } historyMsg := client.BuildHistorySyncRequest(nil, 100) if historyMsg == nil { slog.Error("failed to build history sync request") return } _, err := client.SendMessage(context.Background(), types.JID{ Server: "s.whatsapp.net", User: "status", }, historyMsg) if err != nil { slog.Error("failed to request history sync", "err", err) } else { slog.Info("history sync requested, waiting for server response") } } // analyzeOggOpus tries to extract duration and generate a simple waveform from an Ogg Opus file func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) { // Try to detect if this is a valid Ogg file by checking for the "OggS" signature // at the beginning of the file if len(data) < 4 || string(data[0:4]) != "OggS" { return 0, nil, fmt.Errorf("not a valid Ogg file (missing OggS signature)") } // Parse Ogg pages to find the last page with a valid granule position var lastGranule uint64 var sampleRate uint32 = 48000 // Default Opus sample rate var preSkip uint16 = 0 var foundOpusHead bool for i := 0; i < len(data); { if i+27 >= len(data) { break } if string(data[i:i+4]) != "OggS" { i++ continue } granulePos := binary.LittleEndian.Uint64(data[i+6 : i+14]) pageSeqNum := binary.LittleEndian.Uint32(data[i+18 : i+22]) numSegments := int(data[i+26]) if i+27+numSegments >= len(data) { break } segmentTable := data[i+27 : i+27+numSegments] pageSize := 27 + numSegments for _, segLen := range segmentTable { pageSize += int(segLen) } if !foundOpusHead && pageSeqNum <= 1 { pageData := data[i : i+pageSize] headPos := bytes.Index(pageData, []byte("OpusHead")) if headPos >= 0 && headPos+12 < len(pageData) { headPos += 8 if headPos+12 <= len(pageData) { preSkip = binary.LittleEndian.Uint16(pageData[headPos+10 : headPos+12]) sampleRate = binary.LittleEndian.Uint32(pageData[headPos+12 : headPos+16]) foundOpusHead = true slog.Info("found OpusHead", "sample_rate", sampleRate, "pre_skip", preSkip) } } } if granulePos != 0 { lastGranule = granulePos } i += pageSize } if !foundOpusHead { slog.Warn("opushead not found, using default values") } if lastGranule > 0 { durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate) duration = uint32(math.Ceil(durationSeconds)) slog.Info("calculated Opus duration from granule", "duration_seconds", durationSeconds, "last_granule", lastGranule) } else { slog.Warn("no valid granule position found, using estimation") durationEstimate := float64(len(data)) / 2000.0 duration = uint32(durationEstimate) } if duration < 1 { duration = 1 } else if duration > 300 { duration = 300 } waveform = placeholderWaveform(duration) slog.Info("ogg opus analysis complete", "size_bytes", len(data), "duration_sec", duration, "waveform_bytes", len(waveform)) return duration, waveform, nil } func placeholderWaveform(duration uint32) []byte { const waveformLength = 64 waveform := make([]byte, waveformLength) source := rand.NewSource(int64(duration)) rng := rand.New(source) baseAmplitude := 35.0 frequencyFactor := float64(min(int(duration), 120)) / 30.0 for i := range waveform { pos := float64(i) / float64(waveformLength) val := baseAmplitude * math.Sin(pos*math.Pi*frequencyFactor*8) val += (baseAmplitude / 2) * math.Sin(pos*math.Pi*frequencyFactor*16) val += (rng.Float64() - 0.5) * 15 fadeInOut := math.Sin(pos * math.Pi) val = val * (0.7 + 0.3*fadeInOut) val = val + 50 if val < 0 { val = 0 } else if val > 100 { val = 100 } waveform[i] = byte(val) } return waveform } func (store *MessageStore) GetSenderName(senderJID string) string { var name string var err error if isPostgres { err = store.db.QueryRow( "SELECT name FROM chats WHERE jid = $1 LIMIT 1", senderJID, ).Scan(&name) if err == nil && name != "" { return name } phonePart := senderJID if idx := strings.Index(senderJID, "@"); idx > 0 { phonePart = senderJID[:idx] } err = store.db.QueryRow( "SELECT name FROM chats WHERE jid LIKE $1 LIMIT 1", "%"+phonePart+"%", ).Scan(&name) if err == nil && name != "" { return name } return senderJID } err = store.db.QueryRow( "SELECT name FROM chats WHERE jid = ? LIMIT 1", senderJID, ).Scan(&name) if err == nil && name != "" { return name } phonePart := senderJID if idx := strings.Index(senderJID, "@"); idx > 0 { phonePart = senderJID[:idx] } err = store.db.QueryRow( "SELECT name FROM chats WHERE jid LIKE ? LIMIT 1", "%"+phonePart+"%", ).Scan(&name) if err == nil && name != "" { return name } return senderJID } func (store *MessageStore) FormatMessage(msg MessageInteraction, showChatInfo bool) string { var sb strings.Builder ts := msg.Timestamp.Format("2006-01-02 15:04:05") if showChatInfo && msg.ChatName != "" { sb.WriteString(fmt.Sprintf("[%s] Chat: %s ", ts, msg.ChatName)) } else { sb.WriteString(fmt.Sprintf("[%s] ", ts)) } prefix := "" if msg.MediaType != "" { prefix = fmt.Sprintf("[%s - Message ID: %s - Chat JID: %s] ", msg.MediaType, msg.ID, msg.ChatJID) } senderName := "Me" if !msg.IsFromMe { senderName = store.GetSenderName(msg.Sender) } sb.WriteString(fmt.Sprintf("From: %s: %s%s\n", senderName, prefix, msg.Content)) return sb.String() } func (store *MessageStore) FormatMessagesList(messages []MessageInteraction, showChatInfo bool) string { if len(messages) == 0 { return "No messages to display.\n" } var sb strings.Builder for _, m := range messages { sb.WriteString(store.FormatMessage(m, showChatInfo)) } return sb.String() } func (store *MessageStore) ListMessages(s ListMessagesParams) (string, error) { var args []any var where []string placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } q := ` SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, c.jid, m.id, m.media_type FROM messages m JOIN chats c ON m.chat_jid = c.jid ` if s.After != "" { t, err := time.Parse(time.RFC3339, s.After) if err != nil { return "", fmt.Errorf("invalid after format: %w", err) } where = append(where, "m.timestamp > "+placeholder(len(args)+1)) args = append(args, t) } if s.Before != "" { t, err := time.Parse(time.RFC3339, s.Before) if err != nil { return "", fmt.Errorf("invalid before format: %w", err) } where = append(where, "m.timestamp < "+placeholder(len(args)+1)) args = append(args, t) } if s.SenderPhoneNumber != nil && *s.SenderPhoneNumber != "" { where = append(where, "m.sender = "+placeholder(len(args)+1)) args = append(args, *s.SenderPhoneNumber) } if s.ChatJid != nil && *s.ChatJid != "" { where = append(where, "m.chat_jid = "+placeholder(len(args)+1)) args = append(args, *s.ChatJid) } if s.Query != nil && *s.Query != "" { where = append(where, "LOWER(m.content) LIKE LOWER("+placeholder(len(args)+1)+")") args = append(args, "%"+*s.Query+"%") } if len(where) > 0 { q += " WHERE " + strings.Join(where, " AND ") } q += " ORDER BY m.timestamp DESC" q += " LIMIT " + placeholder(len(args)+1) args = append(args, s.Limit) q += " OFFSET " + placeholder(len(args)+1) args = append(args, s.Page*s.Limit) rows, err := store.db.Query(q, args...) if err != nil { return "", err } defer rows.Close() var msgs []MessageInteraction for rows.Next() { var ( ts time.Time sender string chatName sql.NullString content string isFromMe bool cJid string id string mediaType sql.NullString ) if err := rows.Scan(&ts, &sender, &chatName, &content, &isFromMe, &cJid, &id, &mediaType); err != nil { log.Printf("scan error: %v", err) continue } m := MessageInteraction{ Timestamp: ts, Sender: sender, Content: content, IsFromMe: isFromMe, ChatJID: cJid, ID: id, } if chatName.Valid { m.ChatName = chatName.String } if mediaType.Valid { m.MediaType = mediaType.String } msgs = append(msgs, m) } if s.IncludeContext && len(msgs) > 0 { var all []MessageInteraction for _, m := range msgs { ctx, err := store.GetMessageContext(m.ID, s.ContextBefore, s.ContextAfter) if err != nil { log.Printf("context error for %s: %v", m.ID, err) continue } all = append(all, ctx.Before...) all = append(all, ctx.Message) all = append(all, ctx.After...) } return store.FormatMessagesList(all, true), nil } return store.FormatMessagesList(msgs, true), nil } func (store *MessageStore) GetMessageContext(messageID string, before, after int) (MessageContext, error) { placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } // --- Fetch the target message --- q := ` SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, c.jid, m.id, m.media_type, m.chat_jid FROM messages m JOIN chats c ON m.chat_jid = c.jid WHERE m.id = ` + placeholder(1) var ( ts time.Time sender string chatName sql.NullString content string isFromMe bool chatJid string id string media sql.NullString chatJid2 string ) row := store.db.QueryRow(q, messageID) if err := row.Scan(&ts, &sender, &chatName, &content, &isFromMe, &chatJid, &id, &media, &chatJid2); err != nil { if err == sql.ErrNoRows { return MessageContext{}, fmt.Errorf("message not found: %s", messageID) } return MessageContext{}, err } target := MessageInteraction{ Timestamp: ts, Sender: sender, Content: content, IsFromMe: isFromMe, ChatJID: chatJid, ID: id, } if chatName.Valid { target.ChatName = chatName.String } if media.Valid { target.MediaType = media.String } qBefore := ` SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, c.jid, m.id, m.media_type FROM messages m JOIN chats c ON m.chat_jid = c.jid WHERE m.chat_jid = ` + placeholder(1) + ` AND m.timestamp < ` + placeholder(2) + ` ORDER BY m.timestamp DESC LIMIT ` + placeholder(3) bRows, err := store.db.Query(qBefore, chatJid2, ts, before) if err != nil { return MessageContext{}, err } defer bRows.Close() var beforeMsgs []MessageInteraction for bRows.Next() { var ( bts time.Time bsender sql.NullString bname sql.NullString bcontent string bif bool bjid string bid string bmedia sql.NullString ) if err := bRows.Scan(&bts, &bsender, &bname, &bcontent, &bif, &bjid, &bid, &bmedia); err != nil { continue } m := MessageInteraction{ Timestamp: bts, Sender: bsender.String, Content: bcontent, IsFromMe: bif, ChatJID: bjid, ID: bid, } if bname.Valid { m.ChatName = bname.String } if bmedia.Valid { m.MediaType = bmedia.String } beforeMsgs = append(beforeMsgs, m) } qAfter := ` SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, c.jid, m.id, m.media_type FROM messages m JOIN chats c ON m.chat_jid = c.jid WHERE m.chat_jid = ` + placeholder(1) + ` AND m.timestamp > ` + placeholder(2) + ` ORDER BY m.timestamp ASC LIMIT ` + placeholder(3) aRows, err := store.db.Query(qAfter, chatJid2, ts, after) if err != nil { return MessageContext{}, err } defer aRows.Close() var afterMsgs []MessageInteraction for aRows.Next() { var ( ats time.Time asender sql.NullString aname sql.NullString acontent string aif bool ajid string aid string amedia sql.NullString ) if err := aRows.Scan(&ats, &asender, &aname, &acontent, &aif, &ajid, &aid, &amedia); err != nil { continue } m := MessageInteraction{ Timestamp: ats, Sender: asender.String, Content: acontent, IsFromMe: aif, ChatJID: ajid, ID: aid, } if aname.Valid { m.ChatName = aname.String } if amedia.Valid { m.MediaType = amedia.String } afterMsgs = append(afterMsgs, m) } return MessageContext{ Message: target, Before: beforeMsgs, After: afterMsgs, }, nil } func (store *MessageStore) ListChats( query *string, limit, page int, includeLastMessage bool, sortBy string, ) ([]Chat, error) { placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } q := ` SELECT c.jid, c.name, c.last_message_time, m.content AS last_message, m.sender AS last_sender, m.is_from_me AS last_is_from_me FROM chats c ` if includeLastMessage { q += ` LEFT JOIN messages m ON c.jid = m.chat_jid AND c.last_message_time = m.timestamp ` } var args []any var where []string if query != nil && *query != "" { if isPostgres { where = append(where, "(LOWER(c.name) LIKE LOWER("+placeholder(len(args)+1)+") OR c.jid LIKE "+placeholder(len(args)+2)+")") } else { where = append(where, "(LOWER(c.name) LIKE LOWER(?) OR c.jid LIKE ?)") } args = append(args, "%"+*query+"%", "%"+*query+"%") } if len(where) > 0 { q += " WHERE " + strings.Join(where, " AND ") } order := "c.last_message_time DESC" if sortBy == "name" { order = "c.name ASC" } q += " ORDER BY " + order q += " LIMIT " + placeholder(len(args)+1) + "::int" args = append(args, limit) q += " OFFSET " + placeholder(len(args)+1) + "::int" args = append(args, page*limit) rows, err := store.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var chats []Chat for rows.Next() { var ( jid string name sql.NullString lastMsgTime sql.NullTime lastMsg sql.NullString lastSender sql.NullString lastFromMe sql.NullBool ) if err := rows.Scan(&jid, &name, &lastMsgTime, &lastMsg, &lastSender, &lastFromMe); err != nil { log.Printf("list_chats scan error: %v", err) continue } c := Chat{JID: jid} if name.Valid { c.Name = name.String } if lastMsgTime.Valid { c.LastMessageTime = lastMsgTime.Time } if lastMsg.Valid { c.LastMessage = lastMsg.String } if lastSender.Valid { c.LastSender = lastSender.String } if lastFromMe.Valid { c.LastIsFromMe = lastFromMe.Bool } chats = append(chats, c) } return chats, nil } func (store *MessageStore) SearchContacts(query string) ([]Contact, error) { placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } q := ` SELECT DISTINCT their_jid, first_name FROM whatsmeow_contacts WHERE (LOWER(first_name) LIKE LOWER(` + placeholder(1) + `) OR LOWER(their_jid) LIKE LOWER(` + placeholder(2) + `)) AND their_jid NOT LIKE '%@g.us' ORDER BY first_name, their_jid LIMIT 50 ` args := []any{"%" + query + "%", "%" + query + "%"} rows, err := store.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var contacts []Contact for rows.Next() { var jid, name sql.NullString if err := rows.Scan(&jid, &name); err != nil { continue } if !jid.Valid { continue } phone := strings.Split(jid.String, "@")[0] c := Contact{ PhoneNumber: phone, JID: jid.String, } if name.Valid { c.Name = name.String } contacts = append(contacts, c) } return contacts, nil } func (store *MessageStore) GetContactChats(jid string, limit, page int) ([]Chat, error) { placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } q := ` SELECT DISTINCT c.jid, c.name, c.last_message_time, m.content AS last_message, m.sender AS last_sender, m.is_from_me AS last_is_from_me FROM chats c JOIN messages m ON c.jid = m.chat_jid WHERE m.sender = ` + placeholder(1) + ` OR c.jid = ` + placeholder(2) + ` ORDER BY c.last_message_time DESC LIMIT ` + placeholder(3) + ` OFFSET ` + placeholder(4) args := []any{jid, jid, limit, page * limit} rows, err := store.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var chats []Chat for rows.Next() { var ( cjid string name sql.NullString lmt sql.NullTime lmsg sql.NullString lsender sql.NullString lfromme sql.NullBool ) if err := rows.Scan(&cjid, &name, &lmt, &lmsg, &lsender, &lfromme); err != nil { continue } c := Chat{JID: cjid} if name.Valid { c.Name = name.String } if lmt.Valid { c.LastMessageTime = lmt.Time } if lmsg.Valid { c.LastMessage = lmsg.String } if lsender.Valid { c.LastSender = lsender.String } if lfromme.Valid { c.LastIsFromMe = lfromme.Bool } chats = append(chats, c) } return chats, nil } func (store *MessageStore) GetLastInteraction(jid string) (string, error) { placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } q := ` SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, c.jid, m.id, m.media_type FROM messages m JOIN chats c ON m.chat_jid = c.jid WHERE m.sender = ` + placeholder(1) + ` OR c.jid = ` + placeholder(2) + ` ORDER BY m.timestamp DESC LIMIT 1 ` row := store.db.QueryRow(q, jid, jid) var ( ts time.Time sender string chatName sql.NullString content string isFromMe bool chatJid string id string mediaType sql.NullString ) if err := row.Scan(&ts, &sender, &chatName, &content, &isFromMe, &chatJid, &id, &mediaType); err != nil { if err == sql.ErrNoRows { return "", nil } return "", err } msg := MessageInteraction{ Timestamp: ts, Sender: sender, Content: content, IsFromMe: isFromMe, ChatJID: chatJid, ID: id, } if chatName.Valid { msg.ChatName = chatName.String } if mediaType.Valid { msg.MediaType = mediaType.String } return store.FormatMessage(msg, true), nil } func (store *MessageStore) GetChat(chatJID string, includeLastMessage bool) (*Chat, error) { placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } q := ` SELECT c.jid, c.name, c.last_message_time, m.content AS last_message, m.sender AS last_sender, m.is_from_me AS last_is_from_me FROM chats c ` if includeLastMessage { q += ` LEFT JOIN messages m ON c.jid = m.chat_jid AND c.last_message_time = m.timestamp ` } q += " WHERE c.jid = " + placeholder(1) row := store.db.QueryRow(q, chatJID) var ( jid string name sql.NullString lmt sql.NullTime lmsg sql.NullString lsender sql.NullString lfromme sql.NullBool ) if err := row.Scan(&jid, &name, &lmt, &lmsg, &lsender, &lfromme); err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, err } c := Chat{JID: jid} if name.Valid { c.Name = name.String } if lmt.Valid { c.LastMessageTime = lmt.Time } if lmsg.Valid { c.LastMessage = lmsg.String } if lsender.Valid { c.LastSender = lsender.String } if lfromme.Valid { c.LastIsFromMe = lfromme.Bool } return &c, nil } func (store *MessageStore) GetDirectChatByContact(phone string) (*Chat, error) { placeholder := func(n int) string { if isPostgres { return fmt.Sprintf("$%d", n) } return "?" } q := ` SELECT c.jid, c.name, c.last_message_time, m.content AS last_message, m.sender AS last_sender, m.is_from_me AS last_is_from_me FROM chats c LEFT JOIN messages m ON c.jid = m.chat_jid AND c.last_message_time = m.timestamp WHERE c.jid LIKE ` + placeholder(1) + ` AND c.jid NOT LIKE '%@g.us' LIMIT 1 ` arg := "%" + phone + "%" row := store.db.QueryRow(q, arg) var ( jid string name sql.NullString lmt sql.NullTime lmsg sql.NullString lsender sql.NullString lfromme sql.NullBool ) if err := row.Scan(&jid, &name, &lmt, &lmsg, &lsender, &lfromme); err != nil { return nil, err } return &Chat{ JID: jid, Name: name.String, LastMessageTime: lmt.Time, LastMessage: lmsg.String, LastSender: lsender.String, LastIsFromMe: lfromme.Bool, }, nil } func main() { logger := waLog.Stdout("Client", "INFO", true) logger.Infof("Starting WhatsApp client...") dbLog := waLog.Stdout("Database", "INFO", true) if err := os.MkdirAll("store", 0755); err != nil { logger.Errorf("Failed to create store directory: %v", err) return } slog.SetDefault(bridgelogger.New(os.Getenv("LOG_LEVEL"))) cfg, err := config.LoadConfig() if err != nil { logger.Errorf("Failed to load config: %v", err) return } dialect := "sqlite3" connStr := "file:store/whatsapp.db?_foreign_keys=on" if cfg.DB.IsPostgres { dialect = "postgres" connStr = fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", cfg.DB.User, cfg.DB.Pass, cfg.DB.Host, cfg.DB.Port, "whatsapp") } container, err := sqlstore.New(context.Background(), dialect, connStr, dbLog) if err != nil { logger.Errorf("Failed to connect to database: %v", err) return } deviceStore, err := container.GetFirstDevice(context.Background()) if err != nil { if err == sql.ErrNoRows { deviceStore = container.NewDevice() logger.Infof("Created new device") } else { logger.Errorf("Failed to get device: %v", err) return } } state := wastate.New() version, err := CustomGetLatestVersion(context.Background(), nil) if err != nil { logger.Errorf("Failed to retrieve current WhatsApp Web client Version") } else { store.SetWAVersion(*version) state.SetWAVersion(fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2])) logger.Infof("WhatsApp Web Client Version: %d.%d.%d\n", version[0], version[1], version[2]) } client := whatsmeow.NewClient(deviceStore, logger) if client == nil { logger.Errorf("Failed to create WhatsApp client") return } store.SetOSInfo("Linux", store.GetWAVersion()) store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_CHROME.Enum() messageStore, err := NewMessageStore() if err != nil { logger.Errorf("Failed to initialize message store: %v", err) return } defer messageStore.Close() state.SetLoggedIn(client.Store.ID != nil) // existing session means already logged in const maxOutdatedRetries = 3 var outdatedRetries int var outdatedRetriesMu sync.Mutex client.AddEventHandler(func(evt interface{}) { switch v := evt.(type) { case *events.Message: handleMessage(client, messageStore, v, logger) case *events.HistorySync: handleHistorySync(client, messageStore, v, logger) case *events.Connected: logger.Infof("Connected to WhatsApp") state.SetConnected(true) state.SetLoggedIn(true) state.ClearPairingQR() outdatedRetriesMu.Lock() outdatedRetries = 0 outdatedRetriesMu.Unlock() case *events.Disconnected: logger.Warnf("Disconnected from WhatsApp") state.SetConnected(false) case *events.LoggedOut: logger.Warnf("Device logged out, please scan QR code to log in again") state.SetLoggedIn(false) state.SetConnected(false) case *events.ClientOutdated: outdatedRetriesMu.Lock() outdatedRetries++ n := outdatedRetries outdatedRetriesMu.Unlock() state.SetConnected(false) if n > maxOutdatedRetries { slog.Error("client outdated: exceeded retry budget; whatsmeow library likely needs a real upgrade", "retries", n, "max", maxOutdatedRetries) return } slog.Warn("client outdated (405); refreshing wa version and reconnecting", "attempt", n, "max", maxOutdatedRetries) go func() { time.Sleep(5 * time.Second) newVersion, err := CustomGetLatestVersion(context.Background(), nil) if err != nil { slog.Error("failed to refresh wa version", "err", err) return } store.SetWAVersion(*newVersion) state.SetWAVersion(fmt.Sprintf("%d.%d.%d", newVersion[0], newVersion[1], newVersion[2])) slog.Info("applied refreshed wa version, attempting reconnect", "version", state.WAVersion()) if err := client.Connect(); err != nil { slog.Error("reconnect after wa version refresh failed", "err", err) } }() } }) migrateLIDChatsToPhoneJIDs(client, messageStore, logger, cfg.DB.IsPostgres) // REST server comes up first so /api/auth/status and /api/auth/pairing-qr // are reachable during pairing. WhatsApp connect runs concurrently below. startRESTServer(client, messageStore, cfg, state) // Periodically refresh the WhatsApp Web client version so reconnects // after transient drops use a current version string. Only the next // connection picks up the refreshed value; the active session is unaffected. go func() { t := time.NewTicker(6 * time.Hour) defer t.Stop() for range t.C { v, err := CustomGetLatestVersion(context.Background(), nil) if err != nil { slog.Warn("periodic wa version refresh failed", "err", err) continue } store.SetWAVersion(*v) next := fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]) if next != state.WAVersion() { slog.Info("wa version updated by periodic refresh", "from", state.WAVersion(), "to", next) state.SetWAVersion(next) } } }() // Pair / connect to WhatsApp in a goroutine so main can block on signals. go func() { if client.Store.ID == nil { qrChan, _ := client.GetQRChannel(context.Background()) if err := client.Connect(); err != nil { logger.Errorf("Failed to connect: %v", err) return } for evt := range qrChan { switch evt.Event { case "code": fmt.Println("\nScan this QR code with your WhatsApp app:") qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) if png, err := qrcode.Encode(evt.Code, qrcode.Medium, 256); err == nil { state.SetPairingQRPNG(png) } else { slog.Warn("failed to encode pairing qr as png", "err", err) } case "success": fmt.Println("\nSuccessfully connected and authenticated!") return case "timeout": logger.Errorf("Pairing QR timeout") return } } } else { if err := client.Connect(); err != nil { logger.Errorf("Failed to connect: %v", err) return } } }() exitChan := make(chan os.Signal, 1) signal.Notify(exitChan, syscall.SIGINT, syscall.SIGTERM) fmt.Println("REST server is running. Press Ctrl+C to disconnect and exit.") <-exitChan fmt.Println("Disconnecting...") client.Disconnect() }