initial commit

This commit is contained in:
2025-12-28 21:34:45 +02:00
parent dbffadf0d3
commit 499104c69c
27 changed files with 4043 additions and 2 deletions

105
internal/config/config.go Normal file
View File

@@ -0,0 +1,105 @@
package config
import (
"encoding/json"
"os"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `json:"server"`
WhatsApp []WhatsAppConfig `json:"whatsapp"`
Hooks []Hook `json:"hooks"`
Database DatabaseConfig `json:"database,omitempty"`
Media MediaConfig `json:"media"`
LogLevel string `json:"log_level"`
}
// ServerConfig holds server-specific configuration
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
DefaultCountryCode string `json:"default_country_code,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
AuthKey string `json:"auth_key,omitempty"`
}
// WhatsAppConfig holds configuration for a WhatsApp account
type WhatsAppConfig struct {
ID string `json:"id"`
PhoneNumber string `json:"phone_number"`
SessionPath string `json:"session_path"`
ShowQR bool `json:"show_qr,omitempty"`
}
// Hook represents a registered webhook
type Hook struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers,omitempty"`
Active bool `json:"active"`
Events []string `json:"events,omitempty"`
Description string `json:"description,omitempty"`
}
// DatabaseConfig holds database connection information
type DatabaseConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
}
// MediaConfig holds media storage and delivery configuration
type MediaConfig struct {
DataPath string `json:"data_path"`
Mode string `json:"mode"` // "base64", "link", or "both"
BaseURL string `json:"base_url,omitempty"` // Base URL for media links
}
// Load reads configuration from a file
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Set defaults
if cfg.LogLevel == "" {
cfg.LogLevel = "info"
}
if cfg.Server.Host == "" {
cfg.Server.Host = "localhost"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Media.DataPath == "" {
cfg.Media.DataPath = "./data/media"
}
if cfg.Media.Mode == "" {
cfg.Media.Mode = "link"
}
return &cfg, nil
}
// Save writes configuration to a file
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

161
internal/events/builders.go Normal file
View File

@@ -0,0 +1,161 @@
package events
import (
"context"
"time"
)
// WhatsAppConnectedEvent creates a WhatsApp connected event
func WhatsAppConnectedEvent(ctx context.Context, accountID string, phoneNumber string) Event {
return NewEvent(ctx, EventWhatsAppConnected, map[string]any{
"account_id": accountID,
"phone_number": phoneNumber,
})
}
// WhatsAppDisconnectedEvent creates a WhatsApp disconnected event
func WhatsAppDisconnectedEvent(ctx context.Context, accountID string, reason string) Event {
return NewEvent(ctx, EventWhatsAppDisconnected, map[string]any{
"account_id": accountID,
"reason": reason,
})
}
// WhatsAppPairSuccessEvent creates a WhatsApp pair success event
func WhatsAppPairSuccessEvent(ctx context.Context, accountID string) Event {
return NewEvent(ctx, EventWhatsAppPairSuccess, map[string]any{
"account_id": accountID,
})
}
// WhatsAppPairFailedEvent creates a WhatsApp pair failed event
func WhatsAppPairFailedEvent(ctx context.Context, accountID string, err error) Event {
return NewEvent(ctx, EventWhatsAppPairFailed, map[string]any{
"account_id": accountID,
"error": err.Error(),
})
}
// WhatsAppQRCodeEvent creates a WhatsApp QR code event
func WhatsAppQRCodeEvent(ctx context.Context, accountID string, qrCode string) Event {
return NewEvent(ctx, EventWhatsAppQRCode, map[string]any{
"account_id": accountID,
"qr_code": qrCode,
})
}
// WhatsAppQRTimeoutEvent creates a WhatsApp QR timeout event
func WhatsAppQRTimeoutEvent(ctx context.Context, accountID string) Event {
return NewEvent(ctx, EventWhatsAppQRTimeout, map[string]any{
"account_id": accountID,
})
}
// WhatsAppQRErrorEvent creates a WhatsApp QR error event
func WhatsAppQRErrorEvent(ctx context.Context, accountID string, err error) Event {
return NewEvent(ctx, EventWhatsAppQRError, map[string]any{
"account_id": accountID,
"error": err.Error(),
})
}
// WhatsAppPairEventGeneric creates a generic WhatsApp pairing event
func WhatsAppPairEventGeneric(ctx context.Context, accountID string, eventName string, data map[string]any) Event {
eventData := map[string]any{
"account_id": accountID,
"event": eventName,
}
for k, v := range data {
eventData[k] = v
}
return NewEvent(ctx, EventWhatsAppPairEvent, eventData)
}
// MessageReceivedEvent creates a message received event
func MessageReceivedEvent(ctx context.Context, accountID, messageID, from, to, text string, timestamp time.Time, isGroup bool, groupName, senderName, messageType, mimeType, filename, mediaBase64, mediaURL string) Event {
return NewEvent(ctx, EventMessageReceived, map[string]any{
"account_id": accountID,
"message_id": messageID,
"from": from,
"to": to,
"text": text,
"timestamp": timestamp,
"is_group": isGroup,
"group_name": groupName,
"sender_name": senderName,
"message_type": messageType,
"mime_type": mimeType,
"filename": filename,
"media_base64": mediaBase64,
"media_url": mediaURL,
})
}
// MessageSentEvent creates a message sent event
func MessageSentEvent(ctx context.Context, accountID, messageID, to, text string) Event {
return NewEvent(ctx, EventMessageSent, map[string]any{
"account_id": accountID,
"message_id": messageID,
"to": to,
"text": text,
})
}
// MessageFailedEvent creates a message failed event
func MessageFailedEvent(ctx context.Context, accountID, to, text string, err error) Event {
return NewEvent(ctx, EventMessageFailed, map[string]any{
"account_id": accountID,
"to": to,
"text": text,
"error": err.Error(),
})
}
// MessageDeliveredEvent creates a message delivered event
func MessageDeliveredEvent(ctx context.Context, accountID, messageID, from string, timestamp time.Time) Event {
return NewEvent(ctx, EventMessageDelivered, map[string]any{
"account_id": accountID,
"message_id": messageID,
"from": from,
"timestamp": timestamp,
})
}
// MessageReadEvent creates a message read event
func MessageReadEvent(ctx context.Context, accountID, messageID, from string, timestamp time.Time) Event {
return NewEvent(ctx, EventMessageRead, map[string]any{
"account_id": accountID,
"message_id": messageID,
"from": from,
"timestamp": timestamp,
})
}
// HookTriggeredEvent creates a hook triggered event
func HookTriggeredEvent(ctx context.Context, hookID, hookName, url string, payload any) Event {
return NewEvent(ctx, EventHookTriggered, map[string]any{
"hook_id": hookID,
"hook_name": hookName,
"url": url,
"payload": payload,
})
}
// HookSuccessEvent creates a hook success event
func HookSuccessEvent(ctx context.Context, hookID, hookName string, statusCode int, response any) Event {
return NewEvent(ctx, EventHookSuccess, map[string]any{
"hook_id": hookID,
"hook_name": hookName,
"status_code": statusCode,
"response": response,
})
}
// HookFailedEvent creates a hook failed event
func HookFailedEvent(ctx context.Context, hookID, hookName string, err error) Event {
return NewEvent(ctx, EventHookFailed, map[string]any{
"hook_id": hookID,
"hook_name": hookName,
"error": err.Error(),
})
}

161
internal/events/events.go Normal file
View File

@@ -0,0 +1,161 @@
package events
import (
"context"
"sync"
"time"
)
// EventType represents the type of event
type EventType string
const (
// WhatsApp connection events
EventWhatsAppConnected EventType = "whatsapp.connected"
EventWhatsAppDisconnected EventType = "whatsapp.disconnected"
EventWhatsAppPairSuccess EventType = "whatsapp.pair.success"
EventWhatsAppPairFailed EventType = "whatsapp.pair.failed"
EventWhatsAppQRCode EventType = "whatsapp.qr.code"
EventWhatsAppQRTimeout EventType = "whatsapp.qr.timeout"
EventWhatsAppQRError EventType = "whatsapp.qr.error"
EventWhatsAppPairEvent EventType = "whatsapp.pair.event"
// WhatsApp message events
EventMessageReceived EventType = "message.received"
EventMessageSent EventType = "message.sent"
EventMessageFailed EventType = "message.failed"
EventMessageDelivered EventType = "message.delivered"
EventMessageRead EventType = "message.read"
// Hook events
EventHookTriggered EventType = "hook.triggered"
EventHookSuccess EventType = "hook.success"
EventHookFailed EventType = "hook.failed"
)
// Event represents an event in the system
type Event struct {
Type EventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Data map[string]any `json:"data"`
Context context.Context `json:"-"`
}
// Subscriber is a function that handles events
type Subscriber func(event Event)
// EventBus manages event publishing and subscription
type EventBus struct {
subscribers map[EventType][]Subscriber
mu sync.RWMutex
}
// NewEventBus creates a new event bus
func NewEventBus() *EventBus {
return &EventBus{
subscribers: make(map[EventType][]Subscriber),
}
}
// Subscribe registers a subscriber for a specific event type
func (eb *EventBus) Subscribe(eventType EventType, subscriber Subscriber) {
eb.mu.Lock()
defer eb.mu.Unlock()
if eb.subscribers[eventType] == nil {
eb.subscribers[eventType] = make([]Subscriber, 0)
}
eb.subscribers[eventType] = append(eb.subscribers[eventType], subscriber)
}
// SubscribeAll registers a subscriber for all event types
func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
eb.mu.Lock()
defer eb.mu.Unlock()
allTypes := []EventType{
EventWhatsAppConnected,
EventWhatsAppDisconnected,
EventWhatsAppPairSuccess,
EventWhatsAppPairFailed,
EventMessageReceived,
EventMessageSent,
EventMessageFailed,
EventMessageDelivered,
EventMessageRead,
EventHookTriggered,
EventHookSuccess,
EventHookFailed,
}
for _, eventType := range allTypes {
if eb.subscribers[eventType] == nil {
eb.subscribers[eventType] = make([]Subscriber, 0)
}
eb.subscribers[eventType] = append(eb.subscribers[eventType], subscriber)
}
}
// Publish publishes an event to all subscribers asynchronously
func (eb *EventBus) Publish(event Event) {
eb.mu.RLock()
subscribers := make([]Subscriber, len(eb.subscribers[event.Type]))
copy(subscribers, eb.subscribers[event.Type])
eb.mu.RUnlock()
// Use event context if available, otherwise background
ctx := event.Context
if ctx == nil {
ctx = context.Background()
}
for _, subscriber := range subscribers {
go func(sub Subscriber, evt Event) {
// Check if context is already cancelled
select {
case <-ctx.Done():
return
default:
sub(evt)
}
}(subscriber, event)
}
}
// PublishSync publishes an event to all subscribers synchronously
func (eb *EventBus) PublishSync(event Event) {
eb.mu.RLock()
subscribers := make([]Subscriber, len(eb.subscribers[event.Type]))
copy(subscribers, eb.subscribers[event.Type])
eb.mu.RUnlock()
// Use event context if available, otherwise background
ctx := event.Context
if ctx == nil {
ctx = context.Background()
}
for _, subscriber := range subscribers {
// Check if context is already cancelled
select {
case <-ctx.Done():
return
default:
subscriber(event)
}
}
}
// NewEvent creates a new event with the current timestamp and context
func NewEvent(ctx context.Context, eventType EventType, data map[string]any) Event {
if ctx == nil {
ctx = context.Background()
}
return Event{
Type: eventType,
Timestamp: time.Now(),
Data: data,
Context: ctx,
}
}

365
internal/hooks/manager.go Normal file
View File

@@ -0,0 +1,365 @@
package hooks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/internal/events"
"git.warky.dev/wdevs/whatshooked/internal/logging"
)
// MediaInfo represents media attachment information
type MediaInfo struct {
Type string `json:"type"`
MimeType string `json:"mime_type,omitempty"`
Filename string `json:"filename,omitempty"`
URL string `json:"url,omitempty"`
Base64 string `json:"base64,omitempty"`
}
// MessagePayload represents a message sent to webhooks
type MessagePayload struct {
AccountID string `json:"account_id"`
MessageID string `json:"message_id"`
From string `json:"from"`
To string `json:"to"`
Text string `json:"text"`
Timestamp time.Time `json:"timestamp"`
IsGroup bool `json:"is_group"`
GroupName string `json:"group_name,omitempty"`
SenderName string `json:"sender_name,omitempty"`
MessageType string `json:"message_type"`
Media *MediaInfo `json:"media,omitempty"`
}
// HookResponse represents a response from a webhook
type HookResponse struct {
SendMessage bool `json:"send_message"`
To string `json:"to"`
Text string `json:"text"`
AccountID string `json:"account_id,omitempty"`
}
// Manager manages webhooks
type Manager struct {
hooks map[string]config.Hook
mu sync.RWMutex
client *http.Client
eventBus *events.EventBus
}
// NewManager creates a new hook manager
func NewManager(eventBus *events.EventBus) *Manager {
return &Manager{
hooks: make(map[string]config.Hook),
eventBus: eventBus,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Start begins listening for events
func (m *Manager) Start() {
// Get all possible event types
allEventTypes := []events.EventType{
events.EventWhatsAppConnected,
events.EventWhatsAppDisconnected,
events.EventWhatsAppPairSuccess,
events.EventWhatsAppPairFailed,
events.EventWhatsAppQRCode,
events.EventWhatsAppQRTimeout,
events.EventWhatsAppQRError,
events.EventWhatsAppPairEvent,
events.EventMessageReceived,
events.EventMessageSent,
events.EventMessageFailed,
events.EventMessageDelivered,
events.EventMessageRead,
}
// Subscribe to all event types with a generic handler
for _, eventType := range allEventTypes {
m.eventBus.Subscribe(eventType, m.handleEvent)
}
}
// handleEvent processes any event and triggers relevant hooks
func (m *Manager) handleEvent(event events.Event) {
// Get hooks that are subscribed to this event type
m.mu.RLock()
relevantHooks := make([]config.Hook, 0)
for _, hook := range m.hooks {
if !hook.Active {
continue
}
// If hook has no events specified, subscribe to all events
if len(hook.Events) == 0 {
relevantHooks = append(relevantHooks, hook)
continue
}
// Check if this hook is subscribed to this event type
eventTypeStr := string(event.Type)
for _, subscribedEvent := range hook.Events {
if subscribedEvent == eventTypeStr {
relevantHooks = append(relevantHooks, hook)
break
}
}
}
m.mu.RUnlock()
// Trigger each relevant hook
if len(relevantHooks) > 0 {
m.triggerHooksForEvent(event, relevantHooks)
}
}
// triggerHooksForEvent sends event data to specific hooks
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) {
ctx := event.Context
if ctx == nil {
ctx = context.Background()
}
// Create payload based on event type
var payload interface{}
// For message events, create MessagePayload
if event.Type == events.EventMessageReceived || event.Type == events.EventMessageSent {
messageType := getStringFromEvent(event.Data, "message_type")
msgPayload := MessagePayload{
AccountID: getStringFromEvent(event.Data, "account_id"),
MessageID: getStringFromEvent(event.Data, "message_id"),
From: getStringFromEvent(event.Data, "from"),
To: getStringFromEvent(event.Data, "to"),
Text: getStringFromEvent(event.Data, "text"),
Timestamp: getTimeFromEvent(event.Data, "timestamp"),
IsGroup: getBoolFromEvent(event.Data, "is_group"),
GroupName: getStringFromEvent(event.Data, "group_name"),
SenderName: getStringFromEvent(event.Data, "sender_name"),
MessageType: messageType,
}
// Add media info if message has media content
if messageType != "" && messageType != "text" {
msgPayload.Media = &MediaInfo{
Type: messageType,
MimeType: getStringFromEvent(event.Data, "mime_type"),
Filename: getStringFromEvent(event.Data, "filename"),
URL: getStringFromEvent(event.Data, "media_url"),
Base64: getStringFromEvent(event.Data, "media_base64"),
}
}
payload = msgPayload
} else {
// For other events, create generic payload with event type and data
payload = map[string]interface{}{
"event_type": string(event.Type),
"timestamp": event.Timestamp,
"data": event.Data,
}
}
// Send to each hook with the event type
var wg sync.WaitGroup
for _, hook := range hooks {
wg.Add(1)
go func(h config.Hook, et events.EventType) {
defer wg.Done()
_ = m.sendToHook(ctx, h, payload, et)
}(hook, event.Type)
}
wg.Wait()
}
// Helper functions to extract data from event map
func getStringFromEvent(data map[string]interface{}, key string) string {
if val, ok := data[key].(string); ok {
return val
}
return ""
}
func getTimeFromEvent(data map[string]interface{}, key string) time.Time {
if val, ok := data[key].(time.Time); ok {
return val
}
return time.Time{}
}
func getBoolFromEvent(data map[string]interface{}, key string) bool {
if val, ok := data[key].(bool); ok {
return val
}
return false
}
// LoadHooks loads hooks from configuration
func (m *Manager) LoadHooks(hooks []config.Hook) {
m.mu.Lock()
defer m.mu.Unlock()
for _, hook := range hooks {
m.hooks[hook.ID] = hook
}
logging.Info("Hooks loaded", "count", len(hooks))
}
// AddHook adds a new hook
func (m *Manager) AddHook(hook config.Hook) {
m.mu.Lock()
defer m.mu.Unlock()
m.hooks[hook.ID] = hook
logging.Info("Hook added", "id", hook.ID, "name", hook.Name)
}
// RemoveHook removes a hook
func (m *Manager) RemoveHook(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.hooks[id]; !exists {
return fmt.Errorf("hook %s not found", id)
}
delete(m.hooks, id)
logging.Info("Hook removed", "id", id)
return nil
}
// GetHook returns a hook by ID
func (m *Manager) GetHook(id string) (config.Hook, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
hook, exists := m.hooks[id]
return hook, exists
}
// ListHooks returns all hooks
func (m *Manager) ListHooks() []config.Hook {
m.mu.RLock()
defer m.mu.RUnlock()
hooks := make([]config.Hook, 0, len(m.hooks))
for _, hook := range m.hooks {
hooks = append(hooks, hook)
}
return hooks
}
// sendToHook sends any payload to a specific hook with explicit event type
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
if ctx == nil {
ctx = context.Background()
}
// Publish hook triggered event
m.eventBus.Publish(events.HookTriggeredEvent(ctx, hook.ID, hook.Name, hook.URL, payload))
data, err := json.Marshal(payload)
if err != nil {
logging.Error("Failed to marshal payload", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
return nil
}
// Build URL with query parameters
parsedURL, err := url.Parse(hook.URL)
if err != nil {
logging.Error("Failed to parse hook URL", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
return nil
}
// Extract account_id from payload
var accountID string
switch p := payload.(type) {
case MessagePayload:
accountID = p.AccountID
case map[string]interface{}:
if data, ok := p["data"].(map[string]interface{}); ok {
if aid, ok := data["account_id"].(string); ok {
accountID = aid
}
}
}
// Add query parameters
query := parsedURL.Query()
if eventType != "" {
query.Set("event", string(eventType))
}
if accountID != "" {
query.Set("account_id", accountID)
}
parsedURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, hook.Method, parsedURL.String(), bytes.NewReader(data))
if err != nil {
logging.Error("Failed to create request", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
return nil
}
req.Header.Set("Content-Type", "application/json")
for key, value := range hook.Headers {
req.Header.Set(key, value)
}
logging.Debug("Sending to hook", "hook_id", hook.ID, "url", hook.URL)
resp, err := m.client.Do(req)
if err != nil {
logging.Error("Failed to send to hook", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
return nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
logging.Warn("Hook returned non-success status", "hook_id", hook.ID, "status", resp.StatusCode)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode)))
return nil
}
// Try to parse response
body, err := io.ReadAll(resp.Body)
if err != nil {
logging.Error("Failed to read hook response", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
return nil
}
if len(body) == 0 {
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, nil))
return nil
}
var hookResp HookResponse
if err := json.Unmarshal(body, &hookResp); err != nil {
logging.Debug("Hook response not JSON", "hook_id", hook.ID)
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, string(body)))
return nil
}
logging.Debug("Hook response received", "hook_id", hook.ID, "send_message", hookResp.SendMessage)
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, hookResp))
return &hookResp
}

View File

@@ -0,0 +1,70 @@
package logging
import (
"log/slog"
"os"
"strings"
)
var logger *slog.Logger
// Init initializes the logger with the specified log level
func Init(level string) {
var logLevel slog.Level
switch strings.ToLower(level) {
case "debug":
logLevel = slog.LevelDebug
case "info":
logLevel = slog.LevelInfo
case "warn", "warning":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
opts := &slog.HandlerOptions{
Level: logLevel,
}
handler := slog.NewJSONHandler(os.Stdout, opts)
logger = slog.New(handler)
slog.SetDefault(logger)
}
// Debug logs a debug message
func Debug(msg string, args ...any) {
if logger != nil {
logger.Debug(msg, args...)
}
}
// Info logs an info message
func Info(msg string, args ...any) {
if logger != nil {
logger.Info(msg, args...)
}
}
// Warn logs a warning message
func Warn(msg string, args ...any) {
if logger != nil {
logger.Warn(msg, args...)
}
}
// Error logs an error message
func Error(msg string, args ...any) {
if logger != nil {
logger.Error(msg, args...)
}
}
// With returns a new logger with additional attributes
func With(args ...any) *slog.Logger {
if logger != nil {
return logger.With(args...)
}
return slog.Default()
}

72
internal/utils/phone.go Normal file
View File

@@ -0,0 +1,72 @@
package utils
import (
"fmt"
"strings"
)
// FormatPhoneToJID converts a phone number to WhatsApp JID format
// If the number already contains @, it returns as-is
// Otherwise, applies formatting rules:
// - If starts with 0, assumes no country code and replaces 0 with country code
// - If starts with +, assumes it already has country code
// - Otherwise adds country code if not present
// - Adds @s.whatsapp.net suffix
func FormatPhoneToJID(phone string, defaultCountryCode string) string {
// If already in JID format, return as-is
if strings.Contains(phone, "@") {
return phone
}
// Remove all non-digit characters
cleaned := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, phone)
// If empty after cleaning, return original
if cleaned == "" {
return phone
}
// If number starts with 0, it definitely doesn't have a country code
// Replace the leading 0 with the country code
if strings.HasPrefix(cleaned, "0") && defaultCountryCode != "" {
countryCode := strings.TrimPrefix(defaultCountryCode, "+")
cleaned = countryCode + strings.TrimLeft(cleaned, "0")
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
}
// Remove all leading zeros
cleaned = strings.TrimLeft(cleaned, "0")
// If original phone started with +, it already has country code
if strings.HasPrefix(phone, "+") {
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
}
// Add country code if provided and number doesn't start with it
if defaultCountryCode != "" {
countryCode := strings.TrimPrefix(defaultCountryCode, "+")
if !strings.HasPrefix(cleaned, countryCode) {
cleaned = countryCode + cleaned
}
}
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
}
// IsGroupJID checks if a JID is a group JID
func IsGroupJID(jid string) bool {
return strings.HasSuffix(jid, "@g.us")
}
// IsValidJID checks if a string is a valid WhatsApp JID
func IsValidJID(jid string) bool {
return strings.Contains(jid, "@") &&
(strings.HasSuffix(jid, "@s.whatsapp.net") ||
strings.HasSuffix(jid, "@g.us") ||
strings.HasSuffix(jid, "@broadcast"))
}

View File

@@ -0,0 +1,200 @@
package utils
import (
"testing"
)
func TestFormatPhoneToJID(t *testing.T) {
tests := []struct {
name string
phone string
defaultCountryCode string
want string
}{
{
name: "Already in JID format",
phone: "27834606792@s.whatsapp.net",
defaultCountryCode: "27",
want: "27834606792@s.whatsapp.net",
},
{
name: "Plain number with leading zero",
phone: "0834606792",
defaultCountryCode: "27",
want: "27834606792@s.whatsapp.net",
},
{
name: "Number with country code",
phone: "27834606792",
defaultCountryCode: "27",
want: "27834606792@s.whatsapp.net",
},
{
name: "Number with plus sign",
phone: "+27834606792",
defaultCountryCode: "27",
want: "27834606792@s.whatsapp.net",
},
{
name: "Number without country code config",
phone: "0834606792",
defaultCountryCode: "",
want: "834606792@s.whatsapp.net",
},
{
name: "Number with spaces and dashes",
phone: "083-460-6792",
defaultCountryCode: "27",
want: "27834606792@s.whatsapp.net",
},
{
name: "Number with parentheses",
phone: "(083) 460 6792",
defaultCountryCode: "27",
want: "27834606792@s.whatsapp.net",
},
{
name: "US number with leading 1",
phone: "12025551234",
defaultCountryCode: "1",
want: "12025551234@s.whatsapp.net",
},
{
name: "US number with area code",
phone: "202-555-1234",
defaultCountryCode: "1",
want: "12025551234@s.whatsapp.net",
},
{
name: "Group JID unchanged",
phone: "123456789-1234567890@g.us",
defaultCountryCode: "27",
want: "123456789-1234567890@g.us",
},
{
name: "Number with different country code via plus sign",
phone: "+12025551234",
defaultCountryCode: "27",
want: "12025551234@s.whatsapp.net",
},
{
name: "Country code with plus in config",
phone: "0834606792",
defaultCountryCode: "+27",
want: "27834606792@s.whatsapp.net",
},
{
name: "Empty phone number",
phone: "",
defaultCountryCode: "27",
want: "",
},
{
name: "Multiple leading zeros",
phone: "00834606792",
defaultCountryCode: "27",
want: "27834606792@s.whatsapp.net",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatPhoneToJID(tt.phone, tt.defaultCountryCode)
if got != tt.want {
t.Errorf("FormatPhoneToJID(%q, %q) = %q, want %q",
tt.phone, tt.defaultCountryCode, got, tt.want)
}
})
}
}
func TestIsGroupJID(t *testing.T) {
tests := []struct {
name string
jid string
want bool
}{
{
name: "Individual JID",
jid: "27834606792@s.whatsapp.net",
want: false,
},
{
name: "Group JID",
jid: "123456789-1234567890@g.us",
want: true,
},
{
name: "Empty string",
jid: "",
want: false,
},
{
name: "Invalid JID",
jid: "notajid",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsGroupJID(tt.jid)
if got != tt.want {
t.Errorf("IsGroupJID(%q) = %v, want %v", tt.jid, got, tt.want)
}
})
}
}
func TestIsValidJID(t *testing.T) {
tests := []struct {
name string
jid string
want bool
}{
{
name: "Valid individual JID",
jid: "27834606792@s.whatsapp.net",
want: true,
},
{
name: "Valid group JID",
jid: "123456789-1234567890@g.us",
want: true,
},
{
name: "Valid broadcast JID",
jid: "123456789@broadcast",
want: true,
},
{
name: "Invalid - no @ symbol",
jid: "27834606792",
want: false,
},
{
name: "Invalid - wrong suffix",
jid: "27834606792@invalid.com",
want: false,
},
{
name: "Empty string",
jid: "",
want: false,
},
{
name: "Just @ symbol",
jid: "@",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsValidJID(tt.jid)
if got != tt.want {
t.Errorf("IsValidJID(%q) = %v, want %v", tt.jid, got, tt.want)
}
})
}
}

767
internal/whatsapp/client.go Normal file
View File

@@ -0,0 +1,767 @@
package whatsapp
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/internal/events"
"git.warky.dev/wdevs/whatshooked/internal/logging"
qrterminal "github.com/mdp/qrterminal/v3"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
waEvents "go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
_ "github.com/mattn/go-sqlite3"
)
// Manager manages multiple WhatsApp client connections
type Manager struct {
clients map[string]*Client
mu sync.RWMutex
eventBus *events.EventBus
mediaConfig config.MediaConfig
config *config.Config
configPath string
onConfigUpdate func(*config.Config) error
}
// Client represents a single WhatsApp connection
type Client struct {
ID string
PhoneNumber string
Client *whatsmeow.Client
Container *sqlstore.Container
keepAliveCancel context.CancelFunc
}
// NewManager creates a new WhatsApp manager
func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg *config.Config, configPath string, onConfigUpdate func(*config.Config) error) *Manager {
return &Manager{
clients: make(map[string]*Client),
eventBus: eventBus,
mediaConfig: mediaConfig,
config: cfg,
configPath: configPath,
onConfigUpdate: onConfigUpdate,
}
}
// Connect establishes a connection to a WhatsApp account
func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.clients[cfg.ID]; exists {
return fmt.Errorf("client %s already connected", cfg.ID)
}
// Ensure session directory exists
if err := os.MkdirAll(cfg.SessionPath, 0700); err != nil {
return fmt.Errorf("failed to create session directory: %w", err)
}
// Create database container for session storage
dbPath := filepath.Join(cfg.SessionPath, "session.db")
dbLog := waLog.Stdout("Database", "ERROR", true)
container, err := sqlstore.New(ctx, "sqlite3", "file:"+dbPath+"?_foreign_keys=on", dbLog)
if err != nil {
return fmt.Errorf("failed to create database container: %w", err)
}
// Get device store
deviceStore, err := container.GetFirstDevice(ctx)
if err != nil {
return fmt.Errorf("failed to get device: %w", err)
}
// Set custom client information
//if deviceStore.ID == nil {
// Only set for new devices
deviceStore.Platform = "WhatsHooked"
deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked"
//}
// Create client
clientLog := waLog.Stdout("Client", "ERROR", true)
client := whatsmeow.NewClient(deviceStore, clientLog)
// Register event handler
client.AddEventHandler(func(evt interface{}) {
m.handleEvent(cfg.ID, evt)
})
// Connect
if client.Store.ID == nil {
// New device, need to pair
qrChan, _ := client.GetQRChannel(ctx)
if err := client.Connect(); err != nil {
m.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, cfg.ID, err))
return fmt.Errorf("failed to connect: %w", err)
}
// Wait for QR code
for evt := range qrChan {
switch evt.Event {
case "code":
logging.Info("QR code received for pairing", "account_id", cfg.ID)
// Always display QR code in terminal
fmt.Println("\n========================================")
fmt.Printf("WhatsApp QR Code for account: %s\n", cfg.ID)
fmt.Printf("Phone: %s\n", cfg.PhoneNumber)
fmt.Println("========================================")
fmt.Println("Scan this QR code with WhatsApp on your phone:")
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
fmt.Println("========================================")
// Publish QR code event
m.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, cfg.ID, evt.Code))
case "success":
logging.Info("Pairing successful", "account_id", cfg.ID, "phone", cfg.PhoneNumber)
m.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, cfg.ID))
case "timeout":
logging.Warn("QR code timeout", "account_id", cfg.ID)
m.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, cfg.ID))
case "error":
logging.Error("QR code error", "account_id", cfg.ID, "error", evt.Error)
m.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, cfg.ID, fmt.Errorf("%v", evt.Error)))
default:
logging.Info("Pairing event", "account_id", cfg.ID, "event", evt.Event)
m.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, cfg.ID, evt.Event, map[string]any{
"code": evt.Code,
}))
}
}
} else {
// Already paired, just connect
if err := client.Connect(); err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
if deviceStore.PushName == "" {
deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", cfg.PhoneNumber)
if err := deviceStore.Save(ctx); err != nil {
logging.Error("failed to save device store %s", cfg.ID)
}
}
waClient := &Client{
ID: cfg.ID,
PhoneNumber: cfg.PhoneNumber,
Client: client,
Container: container,
}
m.clients[cfg.ID] = waClient
if client.IsConnected() {
err := client.SendPresence(ctx, types.PresenceAvailable)
if err != nil {
logging.Warn("Failed to send presence", "account_id", cfg.ID, "error", err)
} else {
logging.Debug("Sent presence update", "account_id", cfg.ID)
}
}
// Start keep-alive routine
m.startKeepAlive(waClient)
logging.Info("WhatsApp client connected", "account_id", cfg.ID, "phone", cfg.PhoneNumber)
return nil
}
// Disconnect disconnects a WhatsApp client
func (m *Manager) Disconnect(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
client, exists := m.clients[id]
if !exists {
return fmt.Errorf("client %s not found", id)
}
// Stop keep-alive
if client.keepAliveCancel != nil {
client.keepAliveCancel()
}
client.Client.Disconnect()
delete(m.clients, id)
logging.Info("WhatsApp client disconnected", "account_id", id)
return nil
}
// DisconnectAll disconnects all WhatsApp clients
func (m *Manager) DisconnectAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, client := range m.clients {
// Stop keep-alive
if client.keepAliveCancel != nil {
client.keepAliveCancel()
}
client.Client.Disconnect()
logging.Info("WhatsApp client disconnected", "account_id", id)
}
m.clients = make(map[string]*Client)
}
// SendTextMessage sends a text message from a specific account
func (m *Manager) SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err))
return err
}
msg := &waE2E.Message{
Conversation: proto.String(text),
}
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err))
return fmt.Errorf("failed to send message: %w", err)
}
logging.Debug("Message sent", "account_id", accountID, "to", jid.String())
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), text))
return nil
}
// SendImage sends an image message from a specific account
func (m *Manager) SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return err
}
// Upload the image
uploaded, err := client.Client.Upload(ctx, imageData, whatsmeow.MediaImage)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to upload image: %w", err)
}
// Create image message
msg := &waE2E.Message{
ImageMessage: &waE2E.ImageMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(imageData))),
},
}
// Add caption if provided
if caption != "" {
msg.ImageMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to send image: %w", err)
}
logging.Debug("Image sent", "account_id", accountID, "to", jid.String())
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption))
return nil
}
// SendVideo sends a video message from a specific account
func (m *Manager) SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return err
}
// Upload the video
uploaded, err := client.Client.Upload(ctx, videoData, whatsmeow.MediaVideo)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to upload video: %w", err)
}
// Create video message
msg := &waE2E.Message{
VideoMessage: &waE2E.VideoMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(videoData))),
},
}
// Add caption if provided
if caption != "" {
msg.VideoMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to send video: %w", err)
}
logging.Debug("Video sent", "account_id", accountID, "to", jid.String())
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption))
return nil
}
// SendDocument sends a document message from a specific account
func (m *Manager) SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return err
}
// Upload the document
uploaded, err := client.Client.Upload(ctx, documentData, whatsmeow.MediaDocument)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to upload document: %w", err)
}
// Create document message
msg := &waE2E.Message{
DocumentMessage: &waE2E.DocumentMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(documentData))),
FileName: proto.String(filename),
},
}
// Add caption if provided
if caption != "" {
msg.DocumentMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to send document: %w", err)
}
logging.Debug("Document sent", "account_id", accountID, "to", jid.String(), "filename", filename)
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption))
return nil
}
// GetClient returns a client by ID
func (m *Manager) GetClient(id string) (*Client, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
client, exists := m.clients[id]
return client, exists
}
// handleEvent processes WhatsApp events
func (m *Manager) handleEvent(accountID string, evt interface{}) {
ctx := context.Background()
switch v := evt.(type) {
case *waEvents.Message:
logging.Debug("Message received", "account_id", accountID, "from", v.Info.Sender.String())
// Get the client for downloading media
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
logging.Error("Client not found for message event", "account_id", accountID)
return
}
// Extract message content based on type
var text string
var messageType string = "text"
var mimeType string
var filename string
var mediaBase64 string
var mediaURL string
// Handle text messages
if v.Message.Conversation != nil {
text = *v.Message.Conversation
messageType = "text"
} else if v.Message.ExtendedTextMessage != nil && v.Message.ExtendedTextMessage.Text != nil {
text = *v.Message.ExtendedTextMessage.Text
messageType = "text"
}
// Handle image messages
if v.Message.ImageMessage != nil {
img := v.Message.ImageMessage
messageType = "image"
mimeType = img.GetMimetype()
// Use filename from caption or default
if img.Caption != nil {
text = *img.Caption
}
// Download image
data, err := client.Client.Download(ctx, img)
if err != nil {
logging.Error("Failed to download image", "account_id", accountID, "error", err)
} else {
filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Handle video messages
if v.Message.VideoMessage != nil {
vid := v.Message.VideoMessage
messageType = "video"
mimeType = vid.GetMimetype()
// Use filename from caption or default
if vid.Caption != nil {
text = *vid.Caption
}
// Download video
data, err := client.Client.Download(ctx, vid)
if err != nil {
logging.Error("Failed to download video", "account_id", accountID, "error", err)
} else {
filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Handle document messages
if v.Message.DocumentMessage != nil {
doc := v.Message.DocumentMessage
messageType = "document"
mimeType = doc.GetMimetype()
// Use provided filename or generate one
if doc.FileName != nil {
filename = *doc.FileName
}
// Use caption as text if provided
if doc.Caption != nil {
text = *doc.Caption
}
// Download document
data, err := client.Client.Download(ctx, doc)
if err != nil {
logging.Error("Failed to download document", "account_id", accountID, "error", err)
} else {
filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Publish message received event
m.eventBus.Publish(events.MessageReceivedEvent(
ctx,
accountID,
v.Info.ID,
v.Info.Sender.String(),
v.Info.Chat.String(),
text,
v.Info.Timestamp,
v.Info.IsGroup,
"", // group name - TODO: extract from message
"", // sender name - TODO: extract from message
messageType,
mimeType,
filename,
mediaBase64,
mediaURL,
))
case *waEvents.Connected:
logging.Info("WhatsApp connected", "account_id", accountID)
// Get phone number and client for account
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
phoneNumber := ""
if exists {
// Get the actual phone number from WhatsApp
if client.Client.Store.ID != nil {
actualPhone := client.Client.Store.ID.User
phoneNumber = "+" + actualPhone
// Update phone number in client and config if it's different
if client.PhoneNumber != phoneNumber {
client.PhoneNumber = phoneNumber
logging.Info("Updated phone number from WhatsApp", "account_id", accountID, "phone", phoneNumber)
// Update config
m.updateConfigPhoneNumber(accountID, phoneNumber)
}
} else if client.PhoneNumber != "" {
phoneNumber = client.PhoneNumber
}
}
m.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, accountID, phoneNumber))
case *waEvents.Disconnected:
logging.Warn("WhatsApp disconnected", "account_id", accountID)
m.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, accountID, "connection lost"))
case *waEvents.Receipt:
// Handle delivery and read receipts
if v.Type == types.ReceiptTypeDelivered {
for _, messageID := range v.MessageIDs {
logging.Debug("Message delivered", "account_id", accountID, "message_id", messageID, "from", v.Sender.String())
m.eventBus.Publish(events.MessageDeliveredEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp))
}
} else if v.Type == types.ReceiptTypeRead {
for _, messageID := range v.MessageIDs {
logging.Debug("Message read", "account_id", accountID, "message_id", messageID, "from", v.Sender.String())
m.eventBus.Publish(events.MessageReadEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp))
}
}
}
}
// startKeepAlive starts a goroutine that sends presence updates to keep the connection alive
func (m *Manager) startKeepAlive(client *Client) {
ctx, cancel := context.WithCancel(context.Background())
client.keepAliveCancel = cancel
go func() {
ticker := time.NewTicker(60 * time.Second) // Send presence every 60 seconds
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logging.Debug("Keep-alive stopped", "account_id", client.ID)
return
case <-ticker.C:
// Send presence as "available"
if client.Client.IsConnected() {
err := client.Client.SendPresence(ctx, types.PresenceAvailable)
if err != nil {
logging.Warn("Failed to send presence", "account_id", client.ID, "error", err)
} else {
logging.Debug("Sent presence update", "account_id", client.ID)
}
}
}
}
}()
logging.Info("Keep-alive started", "account_id", client.ID)
}
// updateConfigPhoneNumber updates the phone number for an account in the config and saves it
func (m *Manager) updateConfigPhoneNumber(accountID, phoneNumber string) {
if m.config == nil || m.onConfigUpdate == nil {
return
}
// Find and update the account in the config
for i := range m.config.WhatsApp {
if m.config.WhatsApp[i].ID == accountID {
m.config.WhatsApp[i].PhoneNumber = phoneNumber
// Save the updated config
if err := m.onConfigUpdate(m.config); err != nil {
logging.Error("Failed to save updated config", "account_id", accountID, "error", err)
} else {
logging.Info("Config updated with phone number", "account_id", accountID, "phone", phoneNumber)
}
break
}
}
}
// processMediaData processes media based on the configured mode
// Returns filename and mediaURL, and optionally sets mediaBase64
func (m *Manager) processMediaData(accountID, messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) {
mode := m.mediaConfig.Mode
var filename string
var mediaURL string
// Generate filename
ext := getExtensionFromMimeType(mimeType)
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8])
filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
// Handle base64 mode
if mode == "base64" || mode == "both" {
*mediaBase64 = base64.StdEncoding.EncodeToString(data)
}
// Handle link mode
if mode == "link" || mode == "both" {
// Save file to disk
filePath, err := m.saveMediaFile(accountID, messageID, data, mimeType)
if err != nil {
logging.Error("Failed to save media file", "account_id", accountID, "message_id", messageID, "error", err)
} else {
// Extract just the filename from the full path
filename = filepath.Base(filePath)
mediaURL = m.generateMediaURL(accountID, messageID, filename)
}
}
return filename, mediaURL
}
// saveMediaFile saves media data to disk and returns the file path
func (m *Manager) saveMediaFile(accountID, messageID string, data []byte, mimeType string) (string, error) {
// Create account-specific media directory
mediaDir := filepath.Join(m.mediaConfig.DataPath, accountID)
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return "", fmt.Errorf("failed to create media directory: %w", err)
}
// Generate unique filename using message ID and hash
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes of hash
ext := getExtensionFromMimeType(mimeType)
filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
// Full path to file
filePath := filepath.Join(mediaDir, filename)
// Write file
if err := os.WriteFile(filePath, data, 0644); err != nil {
return "", fmt.Errorf("failed to write media file: %w", err)
}
return filePath, nil
}
// generateMediaURL generates a URL for accessing stored media
func (m *Manager) generateMediaURL(accountID, messageID, filename string) string {
baseURL := m.mediaConfig.BaseURL
if baseURL == "" {
baseURL = "http://localhost:8080" // default
}
return fmt.Sprintf("%s/api/media/%s/%s", baseURL, accountID, filename)
}
// getExtensionFromMimeType returns the file extension for a given MIME type
func getExtensionFromMimeType(mimeType string) string {
extensions := map[string]string{
// Images
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/svg+xml": ".svg",
// Videos
"video/mp4": ".mp4",
"video/mpeg": ".mpeg",
"video/quicktime": ".mov",
"video/x-msvideo": ".avi",
"video/webm": ".webm",
"video/3gpp": ".3gp",
// Documents
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"text/plain": ".txt",
"text/html": ".html",
"application/zip": ".zip",
"application/x-rar-compressed": ".rar",
"application/x-7z-compressed": ".7z",
"application/json": ".json",
"application/xml": ".xml",
// Audio
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/aac": ".aac",
"audio/x-m4a": ".m4a",
}
if ext, ok := extensions[mimeType]; ok {
return ext
}
return "" // No extension if mime type is unknown
}