360 lines
8.0 KiB
Go
360 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// WhatsAppConnector wraps the message-bridge HTTP API
|
|
type WhatsAppConnector struct {
|
|
name string
|
|
baseURL string
|
|
ctx chan struct{}
|
|
callback func()
|
|
mu sync.Mutex
|
|
lastSeen map[string]bool // Track seen message IDs
|
|
}
|
|
|
|
// WhatsAppMessage is the format from message-bridge
|
|
type WhatsAppMessage struct {
|
|
ID string `json:"id"`
|
|
Platform string `json:"platform"`
|
|
From string `json:"from"`
|
|
FromName string `json:"from_name,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
Body string `json:"body"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
IsGroup bool `json:"is_group"`
|
|
GroupName string `json:"group_name,omitempty"`
|
|
MediaType string `json:"media_type,omitempty"`
|
|
MediaURL string `json:"media_url,omitempty"`
|
|
MediaPath string `json:"media_path,omitempty"`
|
|
HasMedia bool `json:"has_media"`
|
|
Transcription string `json:"transcription,omitempty"`
|
|
}
|
|
|
|
// NewWhatsAppConnector creates a new WhatsApp connector
|
|
func NewWhatsAppConnector(name, baseURL string) *WhatsAppConnector {
|
|
return &WhatsAppConnector{
|
|
name: name,
|
|
baseURL: baseURL,
|
|
lastSeen: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (w *WhatsAppConnector) Name() string {
|
|
return w.name
|
|
}
|
|
|
|
func (w *WhatsAppConnector) fetchMessages() ([]WhatsAppMessage, error) {
|
|
resp, err := http.Get(w.baseURL + "/messages")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch messages: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("fetch messages: status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var messages []WhatsAppMessage
|
|
if err := json.NewDecoder(resp.Body).Decode(&messages); err != nil {
|
|
return nil, fmt.Errorf("decode messages: %w", err)
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
func (w *WhatsAppConnector) convertMessage(msg WhatsAppMessage) UnifiedMessage {
|
|
um := UnifiedMessage{
|
|
ID: fmt.Sprintf("%s:%s", w.name, msg.ID),
|
|
Source: w.name,
|
|
SourceUID: msg.ID,
|
|
From: msg.From,
|
|
FromName: msg.FromName,
|
|
Timestamp: msg.Timestamp,
|
|
Body: msg.Body,
|
|
Seen: false, // WhatsApp doesn't have a seen concept in our bridge
|
|
Attachments: []AttachmentMeta{},
|
|
}
|
|
|
|
// Handle group vs direct
|
|
if msg.IsGroup {
|
|
um.To = msg.GroupName
|
|
if um.To == "" {
|
|
um.To = msg.To
|
|
}
|
|
} else {
|
|
// For direct messages, "to" is the recipient (us)
|
|
um.To = msg.To
|
|
}
|
|
|
|
// Use transcription as body for voice messages if available
|
|
if msg.Transcription != "" && msg.Body == "" {
|
|
um.Body = msg.Transcription
|
|
}
|
|
|
|
// Handle media as attachment
|
|
if msg.HasMedia && msg.MediaPath != "" {
|
|
filename := filepath.Base(msg.MediaPath)
|
|
mimeType := "application/octet-stream"
|
|
switch msg.MediaType {
|
|
case "image":
|
|
mimeType = "image/jpeg"
|
|
case "video":
|
|
mimeType = "video/mp4"
|
|
case "voice", "audio":
|
|
mimeType = "audio/ogg"
|
|
case "document":
|
|
mimeType = "application/pdf"
|
|
}
|
|
|
|
// Get file size
|
|
var size int
|
|
if fi, err := os.Stat(msg.MediaPath); err == nil {
|
|
size = int(fi.Size())
|
|
}
|
|
|
|
um.Attachments = append(um.Attachments, AttachmentMeta{
|
|
Name: filename,
|
|
Mime: mimeType,
|
|
Size: size,
|
|
})
|
|
|
|
// Store media path for later retrieval
|
|
um.SourceExtra = msg.MediaPath
|
|
}
|
|
|
|
return um
|
|
}
|
|
|
|
func (w *WhatsAppConnector) FetchNew() ([]UnifiedMessage, error) {
|
|
messages, err := w.fetchMessages()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
var result []UnifiedMessage
|
|
for _, msg := range messages {
|
|
// Skip if already marked as seen
|
|
if w.lastSeen[msg.ID] {
|
|
continue
|
|
}
|
|
// Filter to last 24 hours for "new"
|
|
if time.Since(msg.Timestamp) < 24*time.Hour {
|
|
result = append(result, w.convertMessage(msg))
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (w *WhatsAppConnector) FetchSince(since time.Time) ([]UnifiedMessage, error) {
|
|
messages, err := w.fetchMessages()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result []UnifiedMessage
|
|
for _, msg := range messages {
|
|
if msg.Timestamp.After(since) || msg.Timestamp.Equal(since) {
|
|
result = append(result, w.convertMessage(msg))
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (w *WhatsAppConnector) FetchOne(sourceID string) (*UnifiedMessage, error) {
|
|
messages, err := w.fetchMessages()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, msg := range messages {
|
|
if msg.ID == sourceID {
|
|
um := w.convertMessage(msg)
|
|
return &um, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("message not found: %s", sourceID)
|
|
}
|
|
|
|
func (w *WhatsAppConnector) Archive(sourceID string) error {
|
|
// WhatsApp doesn't support archiving individual messages
|
|
// Mark it as seen in our local tracking
|
|
w.mu.Lock()
|
|
w.lastSeen[sourceID] = true
|
|
w.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (w *WhatsAppConnector) Delete(sourceID string) error {
|
|
// WhatsApp doesn't support deleting messages via API
|
|
return fmt.Errorf("WhatsApp does not support message deletion")
|
|
}
|
|
|
|
func (w *WhatsAppConnector) Reply(sourceID string, body string, attachments []string) error {
|
|
// Get original message to find the chat
|
|
msg, err := w.FetchOne(sourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build send request
|
|
sendReq := map[string]interface{}{
|
|
"to": msg.From, // Reply to sender
|
|
"body": body,
|
|
}
|
|
|
|
// Handle attachments if any
|
|
if len(attachments) > 0 {
|
|
sendReq["media"] = attachments[0] // message-bridge only supports one attachment
|
|
}
|
|
|
|
reqBody, _ := json.Marshal(sendReq)
|
|
resp, err := http.Post(w.baseURL+"/send", "application/json", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return fmt.Errorf("send reply: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("send reply: status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WhatsAppConnector) MarkSeen(sourceID string) error {
|
|
// Track locally
|
|
w.mu.Lock()
|
|
w.lastSeen[sourceID] = true
|
|
w.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (w *WhatsAppConnector) GetAttachment(sourceID string, filename string) ([]byte, error) {
|
|
// First, find the message to get the media path
|
|
messages, err := w.fetchMessages()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, msg := range messages {
|
|
if msg.ID == sourceID && msg.MediaPath != "" {
|
|
// Check if this matches the requested filename
|
|
if strings.HasSuffix(msg.MediaPath, filename) || filepath.Base(msg.MediaPath) == filename {
|
|
return os.ReadFile(msg.MediaPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try fetching from message-bridge media endpoint
|
|
resp, err := http.Get(w.baseURL + "/media/" + filename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch media: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("media not found: %s", filename)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func (w *WhatsAppConnector) Start(callback func()) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
w.ctx = make(chan struct{})
|
|
w.callback = callback
|
|
|
|
// Poll for new messages periodically
|
|
go w.pollLoop()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WhatsAppConnector) pollLoop() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
// Initial fetch to populate seen set
|
|
msgs, err := w.fetchMessages()
|
|
if err == nil {
|
|
w.mu.Lock()
|
|
for _, msg := range msgs {
|
|
w.lastSeen[msg.ID] = true
|
|
}
|
|
w.mu.Unlock()
|
|
}
|
|
|
|
log.Printf("[%s] Started polling for new messages", w.name)
|
|
|
|
for {
|
|
select {
|
|
case <-w.ctx:
|
|
return
|
|
case <-ticker.C:
|
|
w.checkForNew()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *WhatsAppConnector) checkForNew() {
|
|
msgs, err := w.fetchMessages()
|
|
if err != nil {
|
|
log.Printf("[%s] Poll error: %v", w.name, err)
|
|
return
|
|
}
|
|
|
|
hasNew := false
|
|
w.mu.Lock()
|
|
for _, msg := range msgs {
|
|
if !w.lastSeen[msg.ID] {
|
|
hasNew = true
|
|
w.lastSeen[msg.ID] = true
|
|
log.Printf("[%s] New message from %s: %s", w.name, msg.FromName, truncateStr(msg.Body, 50))
|
|
}
|
|
}
|
|
callback := w.callback
|
|
w.mu.Unlock()
|
|
|
|
if hasNew && callback != nil {
|
|
go callback()
|
|
}
|
|
}
|
|
|
|
func truncateStr(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen] + "..."
|
|
}
|
|
|
|
func (w *WhatsAppConnector) Stop() {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if w.ctx != nil {
|
|
close(w.ctx)
|
|
}
|
|
}
|