mirror of
https://github.com/jlengrand/whatsapp-mcp.git
synced 2026-03-10 08:51:23 +00:00
1002 lines
27 KiB
Go
1002 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"reflect"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/mdp/qrterminal"
|
|
|
|
"bytes"
|
|
|
|
"go.mau.fi/whatsmeow"
|
|
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"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// Message represents a chat message for our client
|
|
type Message struct {
|
|
Time time.Time
|
|
Sender string
|
|
Content string
|
|
IsFromMe bool
|
|
}
|
|
|
|
// Database handler for storing message history
|
|
type MessageStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// Initialize message store
|
|
func NewMessageStore() (*MessageStore, error) {
|
|
// Create directory for database if it doesn't exist
|
|
if err := os.MkdirAll("store", 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create store directory: %v", err)
|
|
}
|
|
|
|
// Open SQLite database for messages
|
|
db, err := sql.Open("sqlite3", "file:store/messages.db?_foreign_keys=on")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open message database: %v", err)
|
|
}
|
|
|
|
// Create tables if they don't exist
|
|
_, err = db.Exec(`
|
|
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,
|
|
PRIMARY KEY (id, chat_jid),
|
|
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
|
);
|
|
`)
|
|
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()
|
|
}
|
|
|
|
// Store a chat in the database
|
|
func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error {
|
|
_, err := store.db.Exec(
|
|
"INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)",
|
|
jid, name, lastMessageTime,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Store a message in the database
|
|
func (store *MessageStore) StoreMessage(id, chatJID, sender, content string, timestamp time.Time, isFromMe bool) error {
|
|
// Only store if there's actual content
|
|
if content == "" {
|
|
return nil
|
|
}
|
|
|
|
_, err := store.db.Exec(
|
|
"INSERT OR REPLACE INTO messages (id, chat_jid, sender, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?)",
|
|
id, chatJID, sender, content, timestamp, isFromMe,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Get messages from a chat
|
|
func (store *MessageStore) GetMessages(chatJID string, limit int) ([]Message, error) {
|
|
rows, err := store.db.Query(
|
|
"SELECT sender, content, timestamp, is_from_me 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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
msg.Time = timestamp
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// 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 *waProto.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()
|
|
}
|
|
|
|
// For now, we're ignoring non-text messages
|
|
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"`
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// Create JID for recipient
|
|
var recipientJID types.JID
|
|
var err error
|
|
|
|
// Check if recipient is a JID
|
|
isJID := strings.Contains(recipient, "@")
|
|
|
|
if isJID {
|
|
// Parse the JID string
|
|
recipientJID, err = types.ParseJID(recipient)
|
|
if err != nil {
|
|
return false, fmt.Sprintf("Error parsing JID: %v", err)
|
|
}
|
|
} else {
|
|
// Create JID from phone number
|
|
recipientJID = types.JID{
|
|
User: recipient,
|
|
Server: "s.whatsapp.net", // For personal chats
|
|
}
|
|
}
|
|
|
|
msg := &waProto.Message{}
|
|
|
|
// Check if we have media to send
|
|
if mediaPath != "" {
|
|
// Read media file
|
|
mediaData, err := os.ReadFile(mediaPath)
|
|
if err != nil {
|
|
return false, fmt.Sprintf("Error reading media file: %v", err)
|
|
}
|
|
|
|
// Determine media type and mime type based on file extension
|
|
fileExt := strings.ToLower(mediaPath[strings.LastIndex(mediaPath, ".")+1:])
|
|
var mediaType whatsmeow.MediaType
|
|
var mimeType string
|
|
|
|
// Handle different media types
|
|
switch fileExt {
|
|
// Image types
|
|
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"
|
|
|
|
// Audio types
|
|
case "ogg":
|
|
mediaType = whatsmeow.MediaAudio
|
|
mimeType = "audio/ogg; codecs=opus"
|
|
|
|
// Video types
|
|
case "mp4":
|
|
mediaType = whatsmeow.MediaVideo
|
|
mimeType = "video/mp4"
|
|
case "avi":
|
|
mediaType = whatsmeow.MediaVideo
|
|
mimeType = "video/avi"
|
|
case "mov":
|
|
mediaType = whatsmeow.MediaVideo
|
|
mimeType = "video/quicktime"
|
|
|
|
// Document types (for any other file type)
|
|
default:
|
|
mediaType = whatsmeow.MediaDocument
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
|
|
// Upload media to WhatsApp servers
|
|
resp, err := client.Upload(context.Background(), mediaData, mediaType)
|
|
if err != nil {
|
|
return false, fmt.Sprintf("Error uploading media: %v", err)
|
|
}
|
|
|
|
fmt.Println("Media uploaded", resp)
|
|
|
|
// Create the appropriate message type based on media type
|
|
switch mediaType {
|
|
case whatsmeow.MediaImage:
|
|
msg.ImageMessage = &waProto.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:
|
|
// Handle ogg audio files
|
|
var seconds uint32 = 30 // Default fallback
|
|
var waveform []byte = nil
|
|
|
|
// Try to analyze the ogg file
|
|
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 {
|
|
fmt.Printf("Not an Ogg Opus file: %s\n", mimeType)
|
|
}
|
|
|
|
msg.AudioMessage = &waProto.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 = &waProto.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 = &waProto.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)
|
|
}
|
|
|
|
// Send 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)
|
|
}
|
|
|
|
// Start a REST API server to expose the WhatsApp client functionality
|
|
func startRESTServer(client *whatsmeow.Client, port int) {
|
|
// Handler for sending messages
|
|
http.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) {
|
|
// Only allow POST requests
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Parse the request body
|
|
var req SendMessageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate request
|
|
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
|
|
}
|
|
|
|
fmt.Println("Received request to send message", req.Message, req.MediaPath)
|
|
|
|
// Send the message
|
|
success, message := sendWhatsAppMessage(client, req.Recipient, req.Message, req.MediaPath)
|
|
fmt.Println("Message sent", success, message)
|
|
// Set response headers
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Set appropriate status code
|
|
if !success {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
// Send response
|
|
json.NewEncoder(w).Encode(SendMessageResponse{
|
|
Success: success,
|
|
Message: message,
|
|
})
|
|
})
|
|
|
|
// Start the server
|
|
serverAddr := fmt.Sprintf(":%d", port)
|
|
fmt.Printf("Starting REST API server on %s...\n", serverAddr)
|
|
|
|
// Run server in a goroutine so it doesn't block
|
|
go func() {
|
|
if err := http.ListenAndServe(serverAddr, nil); err != nil {
|
|
fmt.Printf("REST API server error: %v\n", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func main() {
|
|
// Set up logger
|
|
logger := waLog.Stdout("Client", "INFO", true)
|
|
logger.Infof("Starting WhatsApp client...")
|
|
|
|
// Create database connection for storing session data
|
|
dbLog := waLog.Stdout("Database", "INFO", true)
|
|
|
|
// Create directory for database if it doesn't exist
|
|
if err := os.MkdirAll("store", 0755); err != nil {
|
|
logger.Errorf("Failed to create store directory: %v", err)
|
|
return
|
|
}
|
|
|
|
container, err := sqlstore.New("sqlite3", "file:store/whatsapp.db?_foreign_keys=on", dbLog)
|
|
if err != nil {
|
|
logger.Errorf("Failed to connect to database: %v", err)
|
|
return
|
|
}
|
|
|
|
// Get device store - This contains session information
|
|
deviceStore, err := container.GetFirstDevice()
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
// No device exists, create one
|
|
deviceStore = container.NewDevice()
|
|
logger.Infof("Created new device")
|
|
} else {
|
|
logger.Errorf("Failed to get device: %v", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Create client instance
|
|
client := whatsmeow.NewClient(deviceStore, logger)
|
|
if client == nil {
|
|
logger.Errorf("Failed to create WhatsApp client")
|
|
return
|
|
}
|
|
|
|
// Initialize message store
|
|
messageStore, err := NewMessageStore()
|
|
if err != nil {
|
|
logger.Errorf("Failed to initialize message store: %v", err)
|
|
return
|
|
}
|
|
defer messageStore.Close()
|
|
|
|
// Setup event handling for messages and history sync
|
|
client.AddEventHandler(func(evt interface{}) {
|
|
switch v := evt.(type) {
|
|
case *events.Message:
|
|
// Process regular messages
|
|
handleMessage(client, messageStore, v, logger)
|
|
|
|
case *events.HistorySync:
|
|
// Process history sync events
|
|
handleHistorySync(client, messageStore, v, logger)
|
|
|
|
case *events.Connected:
|
|
logger.Infof("Connected to WhatsApp")
|
|
|
|
case *events.LoggedOut:
|
|
logger.Warnf("Device logged out, please scan QR code to log in again")
|
|
}
|
|
})
|
|
|
|
// Create channel to track connection success
|
|
connected := make(chan bool, 1)
|
|
|
|
// Connect to WhatsApp
|
|
if client.Store.ID == nil {
|
|
// No ID stored, this is a new client, need to pair with phone
|
|
qrChan, _ := client.GetQRChannel(context.Background())
|
|
err = client.Connect()
|
|
if err != nil {
|
|
logger.Errorf("Failed to connect: %v", err)
|
|
return
|
|
}
|
|
|
|
// Print QR code for pairing with phone
|
|
for evt := range qrChan {
|
|
if evt.Event == "code" {
|
|
fmt.Println("\nScan this QR code with your WhatsApp app:")
|
|
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
|
|
} else if evt.Event == "success" {
|
|
connected <- true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Wait for connection
|
|
select {
|
|
case <-connected:
|
|
fmt.Println("\nSuccessfully connected and authenticated!")
|
|
case <-time.After(3 * time.Minute):
|
|
logger.Errorf("Timeout waiting for QR code scan")
|
|
return
|
|
}
|
|
} else {
|
|
// Already logged in, just connect
|
|
err = client.Connect()
|
|
if err != nil {
|
|
logger.Errorf("Failed to connect: %v", err)
|
|
return
|
|
}
|
|
connected <- true
|
|
}
|
|
|
|
// Wait a moment for connection to stabilize
|
|
time.Sleep(2 * time.Second)
|
|
|
|
if !client.IsConnected() {
|
|
logger.Errorf("Failed to establish stable connection")
|
|
return
|
|
}
|
|
|
|
fmt.Println("\n✓ Connected to WhatsApp! Type 'help' for commands.")
|
|
|
|
// Start REST API server
|
|
startRESTServer(client, 8080)
|
|
|
|
// Create a channel to keep the main goroutine alive
|
|
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.")
|
|
|
|
// Wait for termination signal
|
|
<-exitChan
|
|
|
|
fmt.Println("Disconnecting...")
|
|
// Disconnect client
|
|
client.Disconnect()
|
|
}
|
|
|
|
// 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 {
|
|
// First, check if chat already exists in database with a name
|
|
var existingName string
|
|
err := messageStore.db.QueryRow("SELECT name FROM chats WHERE jid = ?", chatJID).Scan(&existingName)
|
|
if err == nil && existingName != "" {
|
|
// Chat exists with a name, use that
|
|
logger.Infof("Using existing chat name for %s: %s", chatJID, existingName)
|
|
return existingName
|
|
}
|
|
|
|
// Need to determine chat name
|
|
var name string
|
|
|
|
if jid.Server == "g.us" {
|
|
// This is a group chat
|
|
logger.Infof("Getting name for group: %s", chatJID)
|
|
|
|
// Use conversation data if provided (from history sync)
|
|
if conversation != nil {
|
|
// Extract name from conversation if available
|
|
// This uses type assertions to handle different possible types
|
|
var displayName, convName *string
|
|
// Try to extract the fields we care about regardless of the exact type
|
|
v := reflect.ValueOf(conversation)
|
|
if v.Kind() == reflect.Ptr && !v.IsNil() {
|
|
v = v.Elem()
|
|
|
|
// Try to find DisplayName field
|
|
if displayNameField := v.FieldByName("DisplayName"); displayNameField.IsValid() && displayNameField.Kind() == reflect.Ptr && !displayNameField.IsNil() {
|
|
dn := displayNameField.Elem().String()
|
|
displayName = &dn
|
|
}
|
|
|
|
// Try to find Name field
|
|
if nameField := v.FieldByName("Name"); nameField.IsValid() && nameField.Kind() == reflect.Ptr && !nameField.IsNil() {
|
|
n := nameField.Elem().String()
|
|
convName = &n
|
|
}
|
|
}
|
|
|
|
// Use the name we found
|
|
if displayName != nil && *displayName != "" {
|
|
name = *displayName
|
|
} else if convName != nil && *convName != "" {
|
|
name = *convName
|
|
}
|
|
}
|
|
|
|
// If we didn't get a name, try group info
|
|
if name == "" {
|
|
groupInfo, err := client.GetGroupInfo(jid)
|
|
if err == nil && groupInfo.Name != "" {
|
|
name = groupInfo.Name
|
|
} else {
|
|
// Fallback name for groups
|
|
name = fmt.Sprintf("Group %s", jid.User)
|
|
}
|
|
}
|
|
|
|
logger.Infof("Using group name: %s", name)
|
|
} else {
|
|
// This is an individual contact
|
|
logger.Infof("Getting name for contact: %s", chatJID)
|
|
|
|
// Just use contact info (full name)
|
|
contact, err := client.Store.Contacts.GetContact(jid)
|
|
if err == nil && contact.FullName != "" {
|
|
name = contact.FullName
|
|
} else if sender != "" {
|
|
// Fallback to sender
|
|
name = sender
|
|
} else {
|
|
// Last fallback to JID
|
|
name = jid.User
|
|
}
|
|
|
|
logger.Infof("Using contact name: %s", name)
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// Handle regular incoming messages
|
|
func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *events.Message, logger waLog.Logger) {
|
|
// Extract text content
|
|
content := extractTextContent(msg.Message)
|
|
if content == "" {
|
|
return // Skip non-text messages
|
|
}
|
|
|
|
// Save message to database
|
|
chatJID := msg.Info.Chat.String()
|
|
sender := msg.Info.Sender.User
|
|
|
|
// Get appropriate chat name (pass nil for conversation since we don't have one for regular messages)
|
|
name := GetChatName(client, messageStore, msg.Info.Chat, chatJID, nil, sender, logger)
|
|
|
|
// Update chat in database with the message timestamp (keeps last message time updated)
|
|
err := messageStore.StoreChat(chatJID, name, msg.Info.Timestamp)
|
|
if err != nil {
|
|
logger.Warnf("Failed to store chat: %v", err)
|
|
}
|
|
|
|
// Store message in database
|
|
err = messageStore.StoreMessage(
|
|
msg.Info.ID,
|
|
chatJID,
|
|
sender,
|
|
content,
|
|
msg.Info.Timestamp,
|
|
msg.Info.IsFromMe,
|
|
)
|
|
if err != nil {
|
|
logger.Warnf("Failed to store message: %v", err)
|
|
} else {
|
|
// Log message reception
|
|
timestamp := msg.Info.Timestamp.Format("2006-01-02 15:04:05")
|
|
direction := "←"
|
|
if msg.Info.IsFromMe {
|
|
direction = "→"
|
|
}
|
|
fmt.Printf("[%s] %s %s: %s\n", timestamp, direction, sender, content)
|
|
}
|
|
}
|
|
|
|
// Handle history sync events
|
|
func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, historySync *events.HistorySync, logger waLog.Logger) {
|
|
fmt.Printf("Received history sync event with %d conversations\n", len(historySync.Data.Conversations))
|
|
|
|
syncedCount := 0
|
|
for _, conversation := range historySync.Data.Conversations {
|
|
// Parse JID from the conversation
|
|
if conversation.ID == nil {
|
|
continue
|
|
}
|
|
|
|
chatJID := *conversation.ID
|
|
|
|
// Try to parse the JID
|
|
jid, err := types.ParseJID(chatJID)
|
|
if err != nil {
|
|
logger.Warnf("Failed to parse JID %s: %v", chatJID, err)
|
|
continue
|
|
}
|
|
|
|
// Get appropriate chat name by passing the history sync conversation directly
|
|
name := GetChatName(client, messageStore, jid, chatJID, conversation, "", logger)
|
|
|
|
// Process messages
|
|
messages := conversation.Messages
|
|
if len(messages) > 0 {
|
|
// Update chat with latest message timestamp
|
|
latestMsg := messages[0]
|
|
if latestMsg == nil || latestMsg.Message == nil {
|
|
continue
|
|
}
|
|
|
|
// Get timestamp from message info
|
|
timestamp := time.Time{}
|
|
if ts := latestMsg.Message.GetMessageTimestamp(); ts != 0 {
|
|
timestamp = time.Unix(int64(ts), 0)
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
messageStore.StoreChat(chatJID, name, timestamp)
|
|
|
|
// Store messages
|
|
for _, msg := range messages {
|
|
if msg == nil || msg.Message == nil {
|
|
continue
|
|
}
|
|
|
|
// Extract text content
|
|
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()
|
|
}
|
|
}
|
|
|
|
// Log the message content for debugging
|
|
logger.Infof("Message content: %v", content)
|
|
|
|
// Skip non-text messages
|
|
if content == "" {
|
|
continue
|
|
}
|
|
|
|
// Determine sender
|
|
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 != "" {
|
|
sender = *msg.Message.Key.Participant
|
|
} else if isFromMe {
|
|
sender = client.Store.ID.User
|
|
} else {
|
|
sender = jid.User
|
|
}
|
|
} else {
|
|
sender = jid.User
|
|
}
|
|
|
|
// Store message
|
|
msgID := ""
|
|
if msg.Message.Key != nil && msg.Message.Key.ID != nil {
|
|
msgID = *msg.Message.Key.ID
|
|
}
|
|
|
|
// Get message timestamp
|
|
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,
|
|
)
|
|
if err != nil {
|
|
logger.Warnf("Failed to store history message: %v", err)
|
|
} else {
|
|
syncedCount++
|
|
// Log successful message storage
|
|
logger.Infof("Stored message: [%s] %s -> %s: %s", timestamp.Format("2006-01-02 15:04:05"), sender, chatJID, content)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("History sync complete. Stored %d text messages.\n", syncedCount)
|
|
}
|
|
|
|
// Request history sync from the server
|
|
func requestHistorySync(client *whatsmeow.Client) {
|
|
if client == nil {
|
|
fmt.Println("Client is not initialized. Cannot request history sync.")
|
|
return
|
|
}
|
|
|
|
if !client.IsConnected() {
|
|
fmt.Println("Client is not connected. Please ensure you are connected to WhatsApp first.")
|
|
return
|
|
}
|
|
|
|
if client.Store.ID == nil {
|
|
fmt.Println("Client is not logged in. Please scan the QR code first.")
|
|
return
|
|
}
|
|
|
|
// Build and send a history sync request
|
|
historyMsg := client.BuildHistorySyncRequest(nil, 100)
|
|
if historyMsg == nil {
|
|
fmt.Println("Failed to build history sync request.")
|
|
return
|
|
}
|
|
|
|
_, err := client.SendMessage(context.Background(), types.JID{
|
|
Server: "s.whatsapp.net",
|
|
User: "status",
|
|
}, historyMsg)
|
|
|
|
if err != nil {
|
|
fmt.Printf("Failed to request history sync: %v\n", err)
|
|
} else {
|
|
fmt.Println("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
|
|
|
|
// Scan through the file looking for Ogg pages
|
|
for i := 0; i < len(data); {
|
|
// Check if we have enough data to read Ogg page header
|
|
if i+27 >= len(data) {
|
|
break
|
|
}
|
|
|
|
// Verify Ogg page signature
|
|
if string(data[i:i+4]) != "OggS" {
|
|
// Skip until next potential page
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Extract header fields
|
|
granulePos := binary.LittleEndian.Uint64(data[i+6 : i+14])
|
|
pageSeqNum := binary.LittleEndian.Uint32(data[i+18 : i+22])
|
|
numSegments := int(data[i+26])
|
|
|
|
// Extract segment table
|
|
if i+27+numSegments >= len(data) {
|
|
break
|
|
}
|
|
segmentTable := data[i+27 : i+27+numSegments]
|
|
|
|
// Calculate page size
|
|
pageSize := 27 + numSegments
|
|
for _, segLen := range segmentTable {
|
|
pageSize += int(segLen)
|
|
}
|
|
|
|
// Check if we're looking at an OpusHead packet (should be in first few pages)
|
|
if !foundOpusHead && pageSeqNum <= 1 {
|
|
// Look for "OpusHead" marker in this page
|
|
pageData := data[i : i+pageSize]
|
|
headPos := bytes.Index(pageData, []byte("OpusHead"))
|
|
if headPos >= 0 && headPos+12 < len(pageData) {
|
|
// Found OpusHead, extract sample rate and pre-skip
|
|
// OpusHead format: Magic(8) + Version(1) + Channels(1) + PreSkip(2) + SampleRate(4) + ...
|
|
headPos += 8 // Skip "OpusHead" marker
|
|
// PreSkip is 2 bytes at offset 10
|
|
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
|
|
fmt.Printf("Found OpusHead: sampleRate=%d, preSkip=%d\n", sampleRate, preSkip)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep track of last valid granule position
|
|
if granulePos != 0 {
|
|
lastGranule = granulePos
|
|
}
|
|
|
|
// Move to next page
|
|
i += pageSize
|
|
}
|
|
|
|
if !foundOpusHead {
|
|
fmt.Println("Warning: OpusHead not found, using default values")
|
|
}
|
|
|
|
// Calculate duration based on granule position
|
|
if lastGranule > 0 {
|
|
// Formula for duration: (lastGranule - preSkip) / sampleRate
|
|
durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate)
|
|
duration = uint32(math.Ceil(durationSeconds))
|
|
fmt.Printf("Calculated Opus duration from granule: %f seconds (lastGranule=%d)\n",
|
|
durationSeconds, lastGranule)
|
|
} else {
|
|
// Fallback to rough estimation if granule position not found
|
|
fmt.Println("Warning: No valid granule position found, using estimation")
|
|
durationEstimate := float64(len(data)) / 2000.0 // Very rough approximation
|
|
duration = uint32(durationEstimate)
|
|
}
|
|
|
|
// Make sure we have a reasonable duration (at least 1 second, at most 300 seconds)
|
|
if duration < 1 {
|
|
duration = 1
|
|
} else if duration > 300 {
|
|
duration = 300
|
|
}
|
|
|
|
// Generate waveform
|
|
waveform = placeholderWaveform(duration)
|
|
|
|
fmt.Printf("Ogg Opus analysis: size=%d bytes, calculated duration=%d sec, waveform=%d bytes\n",
|
|
len(data), duration, len(waveform))
|
|
|
|
return duration, waveform, nil
|
|
}
|
|
|
|
// min returns the smaller of x or y
|
|
func min(x, y int) int {
|
|
if x < y {
|
|
return x
|
|
}
|
|
return y
|
|
}
|
|
|
|
// placeholderWaveform generates a synthetic waveform for WhatsApp voice messages
|
|
// that appears natural with some variability based on the duration
|
|
func placeholderWaveform(duration uint32) []byte {
|
|
// WhatsApp expects a 64-byte waveform for voice messages
|
|
const waveformLength = 64
|
|
waveform := make([]byte, waveformLength)
|
|
|
|
// Seed the random number generator for consistent results with the same duration
|
|
rand.Seed(int64(duration))
|
|
|
|
// Create a more natural looking waveform with some patterns and variability
|
|
// rather than completely random values
|
|
|
|
// Base amplitude and frequency - longer messages get faster frequency
|
|
baseAmplitude := 35.0
|
|
frequencyFactor := float64(min(int(duration), 120)) / 30.0
|
|
|
|
for i := range waveform {
|
|
// Position in the waveform (normalized 0-1)
|
|
pos := float64(i) / float64(waveformLength)
|
|
|
|
// Create a wave pattern with some randomness
|
|
// Use multiple sine waves of different frequencies for more natural look
|
|
val := baseAmplitude * math.Sin(pos*math.Pi*frequencyFactor*8)
|
|
val += (baseAmplitude / 2) * math.Sin(pos*math.Pi*frequencyFactor*16)
|
|
|
|
// Add some randomness to make it look more natural
|
|
val += (rand.Float64() - 0.5) * 15
|
|
|
|
// Add some fade-in and fade-out effects
|
|
fadeInOut := math.Sin(pos * math.Pi)
|
|
val = val * (0.7 + 0.3*fadeInOut)
|
|
|
|
// Center around 50 (typical voice baseline)
|
|
val = val + 50
|
|
|
|
// Ensure values stay within WhatsApp's expected range (0-100)
|
|
if val < 0 {
|
|
val = 0
|
|
} else if val > 100 {
|
|
val = 100
|
|
}
|
|
|
|
waveform[i] = byte(val)
|
|
}
|
|
|
|
return waveform
|
|
}
|