Files
whatsapp-mcp/whatsapp-bridge/main.go
Luke Harries 76d332bae7 cleanup
2025-03-30 00:30:44 +00:00

604 lines
15 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mdp/qrterminal"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/whatsmeow"
"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"
waProto "go.mau.fi/whatsmeow/binary/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, &timestamp, &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 {
Phone string `json:"phone"`
Message string `json:"message"`
}
// Function to send a WhatsApp message
func sendWhatsAppMessage(client *whatsmeow.Client, phone, message string) (bool, string) {
if !client.IsConnected() {
return false, "Not connected to WhatsApp"
}
// Create JID for recipient
recipientJID := types.JID{
User: phone,
Server: "s.whatsapp.net", // For personal chats
}
// Send the message
_, err := client.SendMessage(context.Background(), recipientJID, &waProto.Message{
Conversation: proto.String(message),
})
if err != nil {
return false, fmt.Sprintf("Error sending message: %v", err)
}
return true, fmt.Sprintf("Message sent to %s", phone)
}
// 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
fmt.Println("Received request to send message")
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.Phone == "" || req.Message == "" {
http.Error(w, "Phone and message are required", http.StatusBadRequest)
return
}
// Send the message
success, message := sendWhatsAppMessage(client, req.Phone, req.Message)
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()
}
// 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 contact name if possible
name := sender
contact, err := client.Store.Contacts.GetContact(msg.Info.Sender)
if err == nil && contact.FullName != "" {
name = contact.FullName
}
// Update chat in database
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 contact name
name := jid.User
contact, err := client.Store.Contacts.GetContact(jid)
if err == nil && contact.FullName != "" {
name = contact.FullName
}
// 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...")
}
}