Major refactor to library

This commit is contained in:
2025-12-29 09:51:16 +02:00
parent ae169f81e4
commit 767a9e211f
38 changed files with 1073 additions and 492 deletions

133
pkg/whatshooked/options.go Normal file
View File

@@ -0,0 +1,133 @@
package whatshooked
import "git.warky.dev/wdevs/whatshooked/pkg/config"
// Option is a functional option for configuring WhatsHooked
type Option func(*config.Config)
// WithServer configures server settings
func WithServer(host string, port int) Option {
return func(c *config.Config) {
c.Server.Host = host
c.Server.Port = port
}
}
// WithAuth configures authentication
func WithAuth(apiKey, username, password string) Option {
return func(c *config.Config) {
c.Server.AuthKey = apiKey
c.Server.Username = username
c.Server.Password = password
}
}
// WithDefaultCountryCode sets the default country code for phone numbers
func WithDefaultCountryCode(code string) Option {
return func(c *config.Config) {
c.Server.DefaultCountryCode = code
}
}
// WithWhatsAppAccount adds a WhatsApp account
func WithWhatsAppAccount(account config.WhatsAppConfig) Option {
return func(c *config.Config) {
c.WhatsApp = append(c.WhatsApp, account)
}
}
// WithWhatsmeowAccount adds a Whatsmeow account (convenience)
func WithWhatsmeowAccount(id, phoneNumber, sessionPath string, showQR bool) Option {
return func(c *config.Config) {
c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{
ID: id,
Type: "whatsmeow",
PhoneNumber: phoneNumber,
SessionPath: sessionPath,
ShowQR: showQR,
})
}
}
// WithBusinessAPIAccount adds a Business API account (convenience)
func WithBusinessAPIAccount(id, phoneNumber, phoneNumberID, accessToken, verifyToken string) Option {
return func(c *config.Config) {
c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{
ID: id,
Type: "business-api",
PhoneNumber: phoneNumber,
BusinessAPI: &config.BusinessAPIConfig{
PhoneNumberID: phoneNumberID,
AccessToken: accessToken,
VerifyToken: verifyToken,
APIVersion: "v21.0",
},
})
}
}
// WithHook adds a webhook
func WithHook(hook config.Hook) Option {
return func(c *config.Config) {
c.Hooks = append(c.Hooks, hook)
}
}
// WithEventLogger enables event logging
func WithEventLogger(targets []string, fileDir string) Option {
return func(c *config.Config) {
c.EventLogger.Enabled = true
c.EventLogger.Targets = targets
c.EventLogger.FileDir = fileDir
}
}
// WithEventLoggerConfig configures event logger with full config
func WithEventLoggerConfig(cfg config.EventLoggerConfig) Option {
return func(c *config.Config) {
c.EventLogger = cfg
}
}
// WithMedia configures media settings
func WithMedia(dataPath, mode, baseURL string) Option {
return func(c *config.Config) {
c.Media.DataPath = dataPath
c.Media.Mode = mode
c.Media.BaseURL = baseURL
}
}
// WithMediaConfig configures media with full config
func WithMediaConfig(cfg config.MediaConfig) Option {
return func(c *config.Config) {
c.Media = cfg
}
}
// WithLogLevel sets the log level
func WithLogLevel(level string) Option {
return func(c *config.Config) {
c.LogLevel = level
}
}
// WithDatabase configures database settings
func WithDatabase(dbType, host string, port int, username, password, database string) Option {
return func(c *config.Config) {
c.Database.Type = dbType
c.Database.Host = host
c.Database.Port = port
c.Database.Username = username
c.Database.Password = password
c.Database.Database = database
}
}
// WithSQLiteDatabase configures SQLite database
func WithSQLiteDatabase(sqlitePath string) Option {
return func(c *config.Config) {
c.Database.Type = "sqlite"
c.Database.SQLitePath = sqlitePath
}
}

157
pkg/whatshooked/server.go Normal file
View File

@@ -0,0 +1,157 @@
package whatshooked
import (
"context"
"fmt"
"net/http"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/utils"
"go.mau.fi/whatsmeow/types"
)
// Server is the optional built-in HTTP server
type Server struct {
wh *WhatsHooked
httpServer *http.Server
}
// NewServer creates a new HTTP server instance
func NewServer(wh *WhatsHooked) *Server {
return &Server{
wh: wh,
}
}
// Start starts the HTTP server
func (s *Server) Start() error {
// Subscribe to hook success events for two-way communication
s.wh.EventBus().Subscribe(events.EventHookSuccess, s.handleHookResponse)
// Setup routes
mux := s.setupRoutes()
addr := fmt.Sprintf("%s:%d", s.wh.config.Server.Host, s.wh.config.Server.Port)
s.httpServer = &http.Server{
Addr: addr,
Handler: mux,
}
logging.Info("Starting HTTP server",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr)
// Connect to WhatsApp accounts
go func() {
time.Sleep(100 * time.Millisecond) // Give HTTP server a moment to start
logging.Info("HTTP server ready, connecting to WhatsApp accounts")
if err := s.wh.ConnectAll(context.Background()); err != nil {
logging.Error("Failed to connect to WhatsApp accounts", "error", err)
}
}()
// Start server (blocking)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// Stop stops the HTTP server gracefully
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
return s.httpServer.Shutdown(ctx)
}
return nil
}
// setupRoutes configures all HTTP routes
func (s *Server) setupRoutes() *http.ServeMux {
mux := http.NewServeMux()
h := s.wh.Handlers()
// Health check (no auth required)
mux.HandleFunc("/health", h.Health)
// Hook management (with auth)
mux.HandleFunc("/api/hooks", h.Auth(h.Hooks))
mux.HandleFunc("/api/hooks/add", h.Auth(h.AddHook))
mux.HandleFunc("/api/hooks/remove", h.Auth(h.RemoveHook))
// Account management (with auth)
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
// Send messages (with auth)
mux.HandleFunc("/api/send", h.Auth(h.SendMessage))
mux.HandleFunc("/api/send/image", h.Auth(h.SendImage))
mux.HandleFunc("/api/send/video", h.Auth(h.SendVideo))
mux.HandleFunc("/api/send/document", h.Auth(h.SendDocument))
// Serve media files (with auth)
mux.HandleFunc("/api/media/", h.ServeMedia)
// Business API webhooks (no auth - Meta validates via verify_token)
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
logging.Info("HTTP server endpoints configured",
"health", "/health",
"hooks", "/api/hooks",
"accounts", "/api/accounts",
"send", "/api/send")
return mux
}
// handleHookResponse processes hook success events for two-way communication
func (s *Server) handleHookResponse(event events.Event) {
// Use event context for sending message
ctx := event.Context
if ctx == nil {
ctx = context.Background()
}
// Extract response from event data
responseData, ok := event.Data["response"]
if !ok || responseData == nil {
return
}
// Try to cast to HookResponse
resp, ok := responseData.(hooks.HookResponse)
if !ok {
return
}
if !resp.SendMessage {
return
}
// Determine which account to use - default to first available if not specified
targetAccountID := resp.AccountID
if targetAccountID == "" && len(s.wh.config.WhatsApp) > 0 {
targetAccountID = s.wh.config.WhatsApp[0].ID
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(resp.To, s.wh.config.Server.DefaultCountryCode)
// Parse JID
jid, err := types.ParseJID(formattedJID)
if err != nil {
logging.Error("Invalid JID in hook response", "jid", formattedJID, "error", err)
return
}
// Send message with context
if err := s.wh.whatsappMgr.SendTextMessage(ctx, targetAccountID, jid, resp.Text); err != nil {
logging.Error("Failed to send message from hook response", "error", err)
} else {
logging.Info("Message sent from hook response", "account_id", targetAccountID, "to", resp.To)
}
}

View File

@@ -0,0 +1,169 @@
package whatshooked
import (
"context"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/eventlogger"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/handlers"
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
)
// WhatsHooked is the main library instance
type WhatsHooked struct {
config *config.Config
configPath string
eventBus *events.EventBus
whatsappMgr *whatsapp.Manager
hookMgr *hooks.Manager
eventLogger *eventlogger.Logger
handlers *handlers.Handlers
server *Server // Optional built-in server
}
// NewFromFile creates a WhatsHooked instance from a config file
func NewFromFile(configPath string) (*WhatsHooked, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, err
}
// Initialize logging from config
logging.Init(cfg.LogLevel)
return newWithConfig(cfg, configPath)
}
// New creates a WhatsHooked instance with programmatic config
func New(opts ...Option) (*WhatsHooked, error) {
cfg := &config.Config{
Server: config.ServerConfig{
Host: "localhost",
Port: 8080,
},
Media: config.MediaConfig{
DataPath: "./data/media",
Mode: "link",
},
LogLevel: "info",
}
// Apply options
for _, opt := range opts {
opt(cfg)
}
// Initialize logging from config
logging.Init(cfg.LogLevel)
return newWithConfig(cfg, "")
}
// newWithConfig is the internal constructor
func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error) {
wh := &WhatsHooked{
config: cfg,
configPath: configPath,
eventBus: events.NewEventBus(),
}
// Initialize WhatsApp manager
wh.whatsappMgr = whatsapp.NewManager(
wh.eventBus,
cfg.Media,
cfg,
configPath,
func(updatedCfg *config.Config) error {
if configPath != "" {
return config.Save(configPath, updatedCfg)
}
return nil
},
)
// Initialize hook manager
wh.hookMgr = hooks.NewManager(wh.eventBus)
wh.hookMgr.LoadHooks(cfg.Hooks)
wh.hookMgr.Start()
// Initialize event logger if enabled
if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 {
logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database)
if err == nil {
wh.eventLogger = logger
wh.eventBus.SubscribeAll(func(event events.Event) {
wh.eventLogger.Log(event)
})
logging.Info("Event logger initialized", "targets", cfg.EventLogger.Targets)
} else {
logging.Error("Failed to initialize event logger", "error", err)
}
}
// Create handlers
wh.handlers = handlers.New(wh.whatsappMgr, wh.hookMgr, cfg, configPath)
return wh, nil
}
// ConnectAll connects to all configured WhatsApp accounts
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
for _, waCfg := range wh.config.WhatsApp {
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
// Continue connecting to other accounts even if one fails
}
}
return nil
}
// Handlers returns the HTTP handlers instance
func (wh *WhatsHooked) Handlers() *handlers.Handlers {
return wh.handlers
}
// Manager returns the WhatsApp manager
func (wh *WhatsHooked) Manager() *whatsapp.Manager {
return wh.whatsappMgr
}
// EventBus returns the event bus
func (wh *WhatsHooked) EventBus() *events.EventBus {
return wh.eventBus
}
// HookManager returns the hook manager
func (wh *WhatsHooked) HookManager() *hooks.Manager {
return wh.hookMgr
}
// Config returns the configuration
func (wh *WhatsHooked) Config() *config.Config {
return wh.config
}
// Close shuts down all components gracefully
func (wh *WhatsHooked) Close() error {
wh.whatsappMgr.DisconnectAll()
if wh.eventLogger != nil {
return wh.eventLogger.Close()
}
return nil
}
// StartServer starts the built-in HTTP server (convenience method)
func (wh *WhatsHooked) StartServer() error {
wh.server = NewServer(wh)
return wh.server.Start()
}
// StopServer stops the built-in HTTP server
func (wh *WhatsHooked) StopServer(ctx context.Context) error {
if wh.server != nil {
return wh.server.Stop(ctx)
}
return nil
}