diff --git a/API.md b/API.md index c288a853..1b89670e 100644 --- a/API.md +++ b/API.md @@ -705,6 +705,64 @@ curl -s -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data --- +### Add/Update Auto-Reply +* **Method:** `POST` +* **Path:** `/chat/autoreply` +* **Description:** Sets up an auto-reply for a specific phone number. If an auto-reply for the given phone number already exists for the user, it will be updated. If not, a new one will be created. +* **Authentication:** Requires user token. +* **Request Body (JSON):** + ```json + { + "Phone": "1234567890", // Target phone number (normalized, e.g., digits only or international format used by the system) + "Body": "Hello! I am currently unavailable and will get back to you soon." + } + ``` +* **Responses:** + * `201 Created`: If a new auto-reply was successfully created. + ```json + { + "code": 201, + "data": { + "detail": "Auto-reply added successfully", + "id": "generated-unique-id-for-the-rule" + }, + "success": true + } + ``` + * `400 Bad Request`: If `Phone` or `Body` is missing or invalid. + * `409 Conflict`: If an auto-reply for this phone number already exists for the user. + * `500 Internal Server Error`: For other server-side errors. + +--- + +### Delete Auto-Reply +* **Method:** `DELETE` +* **Path:** `/chat/autoreply` +* **Description:** Deletes an existing auto-reply for a specific phone number for the authenticated user. +* **Authentication:** Requires user token. +* **Request Body (JSON):** + ```json + { + "Phone": "1234567890" // Target phone number whose auto-reply rule should be deleted. + } + ``` +* **Responses:** + * `200 OK`: If the auto-reply was successfully deleted. + ```json + { + "code": 200, + "data": { + "detail": "Auto-reply deleted successfully" + }, + "success": true + } + ``` + * `400 Bad Request`: If `Phone` is missing or invalid. + * `404 Not Found`: If no auto-reply rule exists for the given phone number for this user. + * `500 Internal Server Error`: For other server-side errors. + +--- + ## Download Document Downloads a Document from a message and retrieves it Base64 media encoded. Required request parameters are: Url, MediaKey, Mimetype, FileSHA256 and FileLength diff --git a/Dockerfile b/Dockerfile index 4a7cb733..ff73edb4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN apk update && apk add --no-cache \ ffmpeg \ tzdata -ENV TZ="America/Sao_Paulo" +ENV TZ="Asia/Kolkata" WORKDIR /app COPY --from=builder /app/wuzapi /app/ @@ -34,4 +34,4 @@ RUN chown -R root:root /app VOLUME [ "/app/dbdata", "/app/files" ] -ENTRYPOINT ["/app/wuzapi", "--logtype=console", "--color=true"] \ No newline at end of file +ENTRYPOINT ["/app/wuzapi", "--logtype=console", "--color=true"] diff --git a/adduser.sh b/adduser.sh new file mode 100644 index 00000000..ba460ba1 --- /dev/null +++ b/adduser.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Script to create a user via curl + +# Validate the number of arguments +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " + exit 1 +fi + +# Assign arguments to variables +ADMIN_PASS="$1" +USERNAME="$2" +USER_PASSWD="$3" + +# Execute the curl command +curl -X POST http://localhost:8089/admin/users \ +-H "Authorization: ${ADMIN_PASS}" \ +-H "Content-Type: application/json" \ +-d "{\"name\": \"${USERNAME}\", \"token\": \"${USER_PASSWD}\"}" + +echo # Add a newline for cleaner output diff --git a/autoreply_routes.go b/autoreply_routes.go new file mode 100644 index 00000000..f8bfc983 --- /dev/null +++ b/autoreply_routes.go @@ -0,0 +1,28 @@ +package main + +import ( + "github.com/gorilla/mux" + "github.com/justinas/alice" +) + +func registerAutoreplyRoutes(s *server, r *mux.Router, c alice.Chain) { + // Simple Autoreply routes (formerly in /chat/autoreply) + r.Handle("/chat/autoreply", c.Then(s.AddAutoReply())).Methods("POST") + r.Handle("/chat/autoreply", c.Then(s.DeleteAutoReply())).Methods("DELETE") + r.Handle("/chat/autoreply", c.Then(s.GetAutoReplies())).Methods("GET") + + // Mode Autoreply routes (formerly /mode/..., now /autoreply/...) + r.Handle("/autoreply/mode", c.Then(s.AddModeAutoreply())).Methods("POST") + r.Handle("/autoreply/mode", c.Then(s.GetModeAutoreplies())).Methods("GET") + r.Handle("/autoreply/mode", c.Then(s.DeleteModeAutoreply())).Methods("DELETE") + + r.Handle("/autoreply/enablemode", c.Then(s.EnableMode())).Methods("POST") + r.Handle("/autoreply/disablemode", c.Then(s.DisableMode())).Methods("POST") + r.Handle("/autoreply/currentmode", c.Then(s.GetCurrentMode())).Methods("GET") + r.Handle("/autoreply/clearmode", c.Then(s.ClearModes())).Methods("POST") + + // Google Contacts integration routes (already under /autoreply/) + r.Handle("/autoreply/contactgroupauth", c.Then(s.SetGoogleContactsAuthToken())).Methods("POST") + r.Handle("/autoreply/contactgroup", c.Then(s.AddContactGroupToMode())).Methods("POST") + r.Handle("/autoreply/contactgroup", c.Then(s.DeleteContactGroupFromMode())).Methods("DELETE") +} diff --git a/constants.go b/constants.go new file mode 100644 index 00000000..8066504f --- /dev/null +++ b/constants.go @@ -0,0 +1,4 @@ +package main + +// supportedEventTypes lists the valid event types for webhooks. +var supportedEventTypes = []string{"Message", "ReadReceipt", "Presence", "HistorySync", "ChatPresence", "All"} diff --git a/db.go b/db.go index b17ec352..2d1b1442 100644 --- a/db.go +++ b/db.go @@ -24,9 +24,42 @@ func InitializeDatabase(exPath string) (*sqlx.DB, error) { config := getDatabaseConfig(exPath) if config.Type == "postgres" { - return initializePostgres(config) + db, err := initializePostgres(config) + if err != nil { + return nil, err + } + // Create tables for postgres + if err := createTables(db, "postgres"); err != nil { + return nil, fmt.Errorf("failed to create tables for postgres: %w", err) + } + return db, nil + } + + // Default to SQLite + db, err := initializeSQLite(config) + if err != nil { + return nil, err + } + // Call to createTables can be removed if it becomes entirely empty, + // or left if it might handle other non-migrated setup in the future. + // For now, it will be called but do nothing. + if err := createTables(db, "sqlite"); err != nil { + return nil, fmt.Errorf("failed to create tables for sqlite: %w", err) } - return initializeSQLite(config) + return db, nil +} + +func createTables(db *sqlx.DB, dbType string) error { + // The logic for creating autoreply_modes and active_mode tables + // has been moved to the migration system. + // See migrations with ID 6 and 7. + + // The logic for adding google_contacts_auth_token has also been moved to migrations (ID 5). + + // This function is currently a no-op but is kept in case there are future needs + // for table setup outside the main migration sequence or for other DB types + // not covered by the current migration logic. + return nil } func getDatabaseConfig(exPath string) DatabaseConfig { @@ -70,7 +103,6 @@ func initializePostgres(config DatabaseConfig) (*sqlx.DB, error) { if err := db.Ping(); err != nil { return nil, fmt.Errorf("failed to ping postgres database: %w", err) } - return db, nil } @@ -89,6 +121,5 @@ func initializeSQLite(config DatabaseConfig) (*sqlx.DB, error) { if err := db.Ping(); err != nil { return nil, fmt.Errorf("failed to ping sqlite database: %w", err) } - return db, nil } diff --git a/handlers.go b/handlers.go index f1dcc8dd..edcd60d4 100644 --- a/handlers.go +++ b/handlers.go @@ -3,12 +3,13 @@ package main import ( "bytes" "context" - "database/sql" + "database/sql" // Kept for admin user struct "encoding/json" "errors" "fmt" "image" "image/jpeg" + "io" // Now needed for fetchContactsFromGoogleGroupFunc "net/http" "net/url" "os" @@ -31,15 +32,1265 @@ import ( "google.golang.org/protobuf/proto" ) +// Structs for simple Autoreply functionality (formerly in handlers.go) +type AutoReplyRequest struct { + Phone string `json:"Phone"` + Body string `json:"Body"` +} + +type AutoReplyEntry struct { + Phone string `json:"phone"` + Body string `json:"body"` + LastSentAt *time.Time `json:"last_sent_at,omitempty"` +} + +type DeleteAutoReplyRequest struct { + Phone string `json:"Phone"` +} + +// Structs for Mode Autoreply functionality (formerly in handlers.go) +type ModeAutoreplyRequest struct { + ModeName string `json:"ModeName"` + Phone string `json:"Phone"` + Message string `json:"Message"` +} + +type ModeAutoreplyDeleteRequest struct { + ModeName string `json:"ModeName"` + Phone string `json:"Phone,omitempty"` +} + +type EnableModeRequest struct { + ModeName string `json:"ModeName"` +} + +type DisableModeRequest struct { + ModeName string `json:"ModeName"` +} + +type ModeAutoreplyEntry struct { + ModeName string `json:"ModeName"` + Phone string `json:"Phone"` + Message string `json:"Message"` +} + +// AuthTokenRequest defines the structure for the /autoreply/contactgroupauth endpoint +type AuthTokenRequest struct { + AuthToken string `json:"AuthToken"` +} + +// ContactGroupRequest defines the structure for the /autoreply/contactgroup endpoint +type ContactGroupRequest struct { + ModeName string `json:"ModeName"` + GroupName string `json:"GroupName"` + Message string `json:"Message"` +} + +// ContactGroupDeleteRequest defines the structure for the DELETE /autoreply/contactgroup endpoint +type ContactGroupDeleteRequest struct { + ModeName string `json:"ModeName"` + GroupName string `json:"GroupName"` +} + +// Structs for Google People API responses +type GoogleContactGroup struct { + ResourceName string `json:"resourceName"` + Name string `json:"name"` + FormattedName string `json:"formattedName"` + MemberCount int `json:"memberCount"` +} + +type GoogleContactGroupListResponse struct { + ContactGroups []GoogleContactGroup `json:"contactGroups"` + NextPageToken string `json:"nextPageToken"` +} + +type GooglePersonName struct { + DisplayName string `json:"displayName"` +} + +type GooglePhoneNumber struct { + Value string `json:"value"` + CanonicalForm string `json:"canonicalForm"` +} + +type GoogleContactGroupMembership struct { + ContactGroupResourceName string `json:"contactGroupResourceName"` +} + +type GoogleMembership struct { + ContactGroupMembership GoogleContactGroupMembership `json:"contactGroupMembership"` +} + +type GooglePerson struct { + ResourceName string `json:"resourceName"` + Names []GooglePersonName `json:"names"` + PhoneNumbers []GooglePhoneNumber `json:"phoneNumbers"` + Memberships []GoogleMembership `json:"memberships"` +} + +type GoogleConnectionsListResponse struct { + Connections []GooglePerson `json:"connections"` + NextPageToken string `json:"nextPageToken"` + TotalItems int `json:"totalItems"` +} + +type GoogleApiErrorDetail struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` +} + +type GoogleApiError struct { + Error GoogleApiErrorDetail `json:"error"` +} + + +// Struct definitions for Autoreply, Mode Autoreply, and Google Contacts integration +// have been moved to autoreply_types.go or google_contacts_types.go. + type Values struct { m map[string]string } -func (v Values) Get(key string) string { - return v.m[key] +func (v Values) Get(key string) string { + return v.m[key] +} + +// Autoreply, Mode, and Google Contacts handler functions and helpers are now in their respective files: +// - autoreply_handlers.go (for simple autoreply and mode-based autoreply, and isValidModeName) +// - google_contacts_integration.go (for Google Contacts related handlers and helpers: normalizePhoneNumber, fetchContactsFromGoogleGroupFunc) + +// var supportedEventTypes = []string{"Message", "ReadReceipt", "Presence", "HistorySync", "ChatPresence", "All"} // Moved to constants.go + + +// AddAutoReply handles adding a new auto-reply entry for a user. +func (s *server) AddAutoReply() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req AutoReplyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + if req.Phone == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) + return + } + if req.Body == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Body in Payload")) + return + } + + newId, err := GenerateRandomID() + if err != nil { + log.Error().Err(err).Msg("Failed to generate random ID for auto-reply") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to create auto-reply entry")) + return + } + + _, err = s.db.Exec("INSERT INTO autoreplies (id, user_id, phone_number, reply_body, last_sent_at) VALUES ($1, $2, $3, $4, $5)", newId, txtid, req.Phone, req.Body, nil) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + s.Respond(w, r, http.StatusConflict, errors.New("Auto-reply for this phone number already exists for the user")) + return + } + log.Error().Err(err).Str("user_id", txtid).Str("phone", req.Phone).Msg("Failed to add auto-reply") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to add auto-reply")) + return + } + + response := map[string]string{"detail": "Auto-reply added successfully", "id": newId} + responseJson, err := json.Marshal(response) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal success response for AddAutoReply") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to create auto-reply entry")) + return + } + s.Respond(w, r, http.StatusCreated, string(responseJson)) + } +} + +// GetAutoReplies handles fetching all auto-reply entries for a user. +func (s *server) GetAutoReplies() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + rows, err := s.db.Query("SELECT phone_number, reply_body, last_sent_at FROM autoreplies WHERE user_id = $1", txtid) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to query auto-replies") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to retrieve auto-replies")) + return + } + defer rows.Close() + + var replies []AutoReplyEntry + for rows.Next() { + var entry AutoReplyEntry + var lastSentAt sql.NullTime + if err := rows.Scan(&entry.Phone, &entry.Body, &lastSentAt); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to scan auto-reply row") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to process auto-reply data")) + return + } + if lastSentAt.Valid { + entry.LastSentAt = &lastSentAt.Time + } else { + entry.LastSentAt = nil + } + replies = append(replies, entry) + } + + if err = rows.Err(); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Error iterating auto-reply rows") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to read auto-replies")) + return + } + + responseJson, err := json.Marshal(replies) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal auto-replies response") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to format auto-replies response")) + return + } + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// DeleteAutoReply handles deleting an auto-reply entry for a user. +func (s *server) DeleteAutoReply() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req DeleteAutoReplyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + if req.Phone == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) + return + } + + result, err := s.db.Exec("DELETE FROM autoreplies WHERE user_id = $1 AND phone_number = $2", txtid, req.Phone) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("phone", req.Phone).Msg("Failed to delete auto-reply") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to delete auto-reply")) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("phone", req.Phone).Msg("Failed to check affected rows after delete") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to confirm deletion status")) + return + } + + if rowsAffected == 0 { + s.Respond(w, r, http.StatusNotFound, errors.New("Auto-reply not found for this user and phone number")) + return + } + + response := map[string]string{"detail": "Auto-reply deleted successfully"} + responseJson, err := json.Marshal(response) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal success response for DeleteAutoReply") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to process deletion confirmation")) + return + } + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// isValidModeName checks if the mode name is purely alphanumeric. +func isValidModeName(modeName string) bool { + if modeName == "" { + return false + } + for _, r := range modeName { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} + +// AddModeAutoreply handles adding or updating an autoreply message for a specific mode. +func (s *server) AddModeAutoreply() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req ModeAutoreplyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + modeName := strings.ToLower(req.ModeName) + if !isValidModeName(modeName) { + s.Respond(w, r, http.StatusBadRequest, errors.New("Invalid ModeName: must be alphanumeric")) + return + } + + if req.Phone == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) + return + } + if req.Message == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Message in Payload")) + return + } + + var query string + dbType := s.db.DriverName() + if dbType == "postgres" { + query = `INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, mode_name, phone_number) + DO UPDATE SET message = EXCLUDED.message;` + } else { // sqlite + query = `INSERT OR REPLACE INTO autoreply_modes (user_id, mode_name, phone_number, message) + VALUES (?, ?, ?, ?);` + } + + _, err := s.db.Exec(query, txtid, modeName, req.Phone, req.Message) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("mode_name", modeName).Msg("Failed to add/update mode autoreply") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to save mode autoreply")) + return + } + + response := map[string]string{"detail": "Mode autoreply added/updated successfully"} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusCreated, string(responseJson)) + } +} + +// DeleteModeAutoreply handles deleting autoreply messages for a specific mode. +func (s *server) DeleteModeAutoreply() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req ModeAutoreplyDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + modeName := strings.ToLower(req.ModeName) + if !isValidModeName(modeName) { + s.Respond(w, r, http.StatusBadRequest, errors.New("Invalid ModeName: must be alphanumeric")) + return + } + + var result sql.Result + var err error + + if req.Phone != "" { + query := "DELETE FROM autoreply_modes WHERE user_id = $1 AND mode_name = $2 AND phone_number = $3" + if s.db.DriverName() == "sqlite" { + query = "DELETE FROM autoreply_modes WHERE user_id = ? AND mode_name = ? AND phone_number = ?" + } + result, err = s.db.Exec(query, txtid, modeName, req.Phone) + } else { + query := "DELETE FROM autoreply_modes WHERE user_id = $1 AND mode_name = $2" + if s.db.DriverName() == "sqlite" { + query = "DELETE FROM autoreply_modes WHERE user_id = ? AND mode_name = ?" + } + result, err = s.db.Exec(query, txtid, modeName) + } + + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("mode_name", modeName).Msg("Failed to delete mode autoreply") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to delete mode autoreply")) + return + } + + rowsAffected, _ := result.RowsAffected() + detailMsg := fmt.Sprintf("%d autoreply entry(s) deleted for mode '%s'", rowsAffected, modeName) + if rowsAffected == 0 { + detailMsg = fmt.Sprintf("No autoreply entries found or deleted for mode '%s'", modeName) + } + + response := map[string]string{"detail": detailMsg} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// GetModeAutoreplies handles fetching autoreply messages, optionally filtered by mode. +func (s *server) GetModeAutoreplies() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + modeNameParam := r.URL.Query().Get("modeName") + + var rows *sql.Rows + var err error + + if modeNameParam != "" { + modeName := strings.ToLower(modeNameParam) + if !isValidModeName(modeName) { + s.Respond(w, r, http.StatusBadRequest, errors.New("Invalid modeName parameter: must be alphanumeric")) + return + } + query := "SELECT mode_name, phone_number, message FROM autoreply_modes WHERE user_id = $1 AND mode_name = $2" + if s.db.DriverName() == "sqlite" { + query = "SELECT mode_name, phone_number, message FROM autoreply_modes WHERE user_id = ? AND mode_name = ?" + } + rows, err = s.db.Query(query, txtid, modeName) + } else { + query := "SELECT mode_name, phone_number, message FROM autoreply_modes WHERE user_id = $1" + if s.db.DriverName() == "sqlite" { + query = "SELECT mode_name, phone_number, message FROM autoreply_modes WHERE user_id = ?" + } + rows, err = s.db.Query(query, txtid) + } + + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to query mode autoreplies") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to retrieve mode autoreplies")) + return + } + defer rows.Close() + + var entries []ModeAutoreplyEntry + for rows.Next() { + var entry ModeAutoreplyEntry + if err := rows.Scan(&entry.ModeName, &entry.Phone, &entry.Message); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to scan mode autoreply row") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to process mode autoreply data")) + return + } + entries = append(entries, entry) + } + + if err = rows.Err(); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Error iterating mode autoreply rows") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to read mode autoreplies")) + return + } + + responseJson, _ := json.Marshal(entries) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// EnableMode activates a specific autoreply mode for the user. +func (s *server) EnableMode() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req EnableModeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + modeName := strings.ToLower(req.ModeName) + if !isValidModeName(modeName) { + s.Respond(w, r, http.StatusBadRequest, errors.New("Invalid ModeName: must be alphanumeric")) + return + } + + dbType := s.db.DriverName() + + tx, err := s.db.Beginx() + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to begin transaction for EnableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode")) + return + } + defer tx.Rollback() + + clearAutorepliesQuery := "DELETE FROM autoreplies WHERE user_id = $1" + if dbType == "sqlite" { + clearAutorepliesQuery = "DELETE FROM autoreplies WHERE user_id = ?" + } + if _, err := tx.Exec(clearAutorepliesQuery, txtid); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to clear autoreplies for EnableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode (clear old)")) + return + } + + type modeEntry struct { + PhoneNumber string `db:"phone_number"` + Message string `db:"message"` + } + var entriesToActivate []modeEntry + fetchModeEntriesQuery := "SELECT phone_number, message FROM autoreply_modes WHERE user_id = $1 AND mode_name = $2" + if dbType == "sqlite" { + fetchModeEntriesQuery = "SELECT phone_number, message FROM autoreply_modes WHERE user_id = ? AND mode_name = ?" + } + err = tx.Select(&entriesToActivate, fetchModeEntriesQuery, txtid, modeName) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("mode_name", modeName).Msg("Failed to fetch mode entries for EnableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode (fetch new)")) + return + } + + if len(entriesToActivate) == 0 { + log.Warn().Str("user_id", txtid).Str("mode_name", modeName).Msg("EnableMode called for a mode with no entries or mode does not exist") + } + + insertAutoreplyQuery := "INSERT INTO autoreplies (id, user_id, phone_number, reply_body, last_sent_at) VALUES ($1, $2, $3, $4, NULL)" + if dbType == "sqlite" { + insertAutoreplyQuery = "INSERT INTO autoreplies (id, user_id, phone_number, reply_body, last_sent_at) VALUES (?, ?, ?, ?, NULL)" + } + stmt, err := tx.Preparex(insertAutoreplyQuery) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to prepare statement for inserting autoreplies") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode (prepare insert)")) + return + } + defer stmt.Close() + + for _, entry := range entriesToActivate { + newId, idErr := GenerateRandomID() + if idErr != nil { + log.Error().Err(idErr).Msg("Failed to generate random ID for autoreply entry in EnableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode (generate id)")) + return + } + if _, err := stmt.Exec(newId, txtid, entry.PhoneNumber, entry.Message); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to insert autoreply entry in EnableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode (insert new)")) + return + } + } + + var updateActiveModeQuery string + if dbType == "postgres" { + updateActiveModeQuery = `INSERT INTO active_mode (user_id, current_mode_name) VALUES ($1, $2) + ON CONFLICT(user_id) DO UPDATE SET current_mode_name = EXCLUDED.current_mode_name;` + } else { + updateActiveModeQuery = `INSERT OR REPLACE INTO active_mode (user_id, current_mode_name) VALUES (?, ?);` + } + if _, err := tx.Exec(updateActiveModeQuery, txtid, modeName); err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("mode_name", modeName).Msg("Failed to update active_mode for EnableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode (update active)")) + return + } + + if err := tx.Commit(); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to commit transaction for EnableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to enable mode (commit)")) + return + } + + detailMsg := fmt.Sprintf("Mode '%s' enabled successfully. %d autoreplies activated.", modeName, len(entriesToActivate)) + response := map[string]string{"detail": detailMsg} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// DisableMode deactivates a specific autoreply mode if it's currently active. +func (s *server) DisableMode() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req DisableModeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + modeName := strings.ToLower(req.ModeName) + if !isValidModeName(modeName) { + s.Respond(w, r, http.StatusBadRequest, errors.New("Invalid ModeName: must be alphanumeric")) + return + } + + dbType := s.db.DriverName() + + tx, err := s.db.Beginx() + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to begin transaction for DisableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to disable mode")) + return + } + defer tx.Rollback() + + var currentActiveMode sql.NullString + getActiveModeQuery := "SELECT current_mode_name FROM active_mode WHERE user_id = $1" + if dbType == "sqlite" { + getActiveModeQuery = "SELECT current_mode_name FROM active_mode WHERE user_id = ?" + } + err = tx.Get(¤tActiveMode, getActiveModeQuery, txtid) + if err != nil && err != sql.ErrNoRows { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to query active_mode for DisableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to disable mode (check active)")) + return + } + + if currentActiveMode.Valid && currentActiveMode.String == modeName { + clearAutorepliesQuery := "DELETE FROM autoreplies WHERE user_id = $1" + if dbType == "sqlite" { + clearAutorepliesQuery = "DELETE FROM autoreplies WHERE user_id = ?" + } + if _, err := tx.Exec(clearAutorepliesQuery, txtid); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to clear autoreplies for DisableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to disable mode (clear replies)")) + return + } + + updateActiveModeQuery := "UPDATE active_mode SET current_mode_name = NULL WHERE user_id = $1" + if dbType == "sqlite" { + updateActiveModeQuery = "UPDATE active_mode SET current_mode_name = NULL WHERE user_id = ?" + } + if _, err := tx.Exec(updateActiveModeQuery, txtid); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to set active_mode to NULL for DisableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to disable mode (set null)")) + return + } + + if err := tx.Commit(); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to commit transaction for DisableMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to disable mode (commit)")) + return + } + response := map[string]string{"detail": fmt.Sprintf("Mode '%s' disabled successfully.", modeName)} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } else { + response := map[string]string{"detail": fmt.Sprintf("Mode '%s' was not active or does not exist. No changes made.", modeName)} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } + } +} + +// GetCurrentMode retrieves the currently active autoreply mode for the user. +func (s *server) GetCurrentMode() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + dbType := s.db.DriverName() + + var currentMode sql.NullString + query := "SELECT current_mode_name FROM active_mode WHERE user_id = $1" + if dbType == "sqlite" { + query = "SELECT current_mode_name FROM active_mode WHERE user_id = ?" + } + err := s.db.Get(¤tMode, query, txtid) + + if err != nil && err != sql.ErrNoRows { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to get current mode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to retrieve current mode")) + return + } + + var modeNameStr string + if currentMode.Valid { + modeNameStr = currentMode.String + } else { + modeNameStr = "" + } + + response := map[string]interface{}{"current_mode_name": modeNameStr} + if !currentMode.Valid { + response = map[string]interface{}{"current_mode_name": nil} + } + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// ClearModes deactivates any active mode and clears all autoreplies for the user. +func (s *server) ClearModes() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + dbType := s.db.DriverName() + + tx, err := s.db.Beginx() + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to begin transaction for ClearModes") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to clear modes")) + return + } + defer tx.Rollback() + + clearAutorepliesQuery := "DELETE FROM autoreplies WHERE user_id = $1" + if dbType == "sqlite" { + clearAutorepliesQuery = "DELETE FROM autoreplies WHERE user_id = ?" + } + if _, err := tx.Exec(clearAutorepliesQuery, txtid); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to clear autoreplies for ClearModes") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to clear modes (clear replies)")) + return + } + + var updateActiveModeQuery string + if dbType == "postgres" { + updateActiveModeQuery = `INSERT INTO active_mode (user_id, current_mode_name) VALUES ($1, NULL) + ON CONFLICT(user_id) DO UPDATE SET current_mode_name = NULL;` + } else { + updateActiveModeQuery = `INSERT INTO active_mode (user_id, current_mode_name) VALUES (?, NULL) + ON CONFLICT(user_id) DO UPDATE SET current_mode_name = NULL;` + } + if dbType == "sqlite" { + res, err_update := tx.Exec("UPDATE active_mode SET current_mode_name = NULL WHERE user_id = ?", txtid) + if err_update != nil { + log.Error().Err(err_update).Str("user_id", txtid).Msg("Failed to update active_mode to NULL for ClearModes (SQLite)") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to clear modes (set null)")) + return + } + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + _, err_insert := tx.Exec("INSERT INTO active_mode (user_id, current_mode_name) VALUES (?, NULL)", txtid) + if err_insert != nil { + log.Error().Err(err_insert).Str("user_id", txtid).Msg("Failed to insert into active_mode for ClearModes (SQLite)") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to clear modes (insert null)")) + return + } + } + } else { + if _, err := tx.Exec(updateActiveModeQuery, txtid); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to set active_mode to NULL for ClearModes (Postgres)") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to clear modes (set null)")) + return + } + } + + if err := tx.Commit(); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to commit transaction for ClearModes") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to clear modes (commit)")) + return + } + + response := map[string]string{"detail": "All modes cleared and current mode deactivated successfully."} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + + +// normalizePhoneNumber attempts to clean and normalize a phone number string. +// It removes non-numeric characters (except initial '+'), and for 10-digit numbers, +// assumes it's an Indian number and prefixes "91". +// Returns the normalized number (digits only) or an error. +func normalizePhoneNumber(phone string) (string, error) { + // Keep initial '+' but remove other non-numeric characters + var cleaned strings.Builder + hasPlus := strings.HasPrefix(phone, "+") + if hasPlus { + phone = phone[1:] // Temporarily remove plus for cleaning + } + + for _, r := range phone { + if r >= '0' && r <= '9' { + cleaned.WriteRune(r) + } + } + normalized := cleaned.String() + + if normalized == "" { + return "", errors.New("phone number is empty after cleaning") + } + + // If original had '+', it's likely an international number, keep as is (digits only) + // Otherwise, apply length-based rules (e.g., for Indian numbers) + if !hasPlus { + if len(normalized) == 10 { + // Assume 10-digit numbers without '+' are Indian, prefix with 91 + // This is a common convention but might need adjustment for other regions/rules + return "91" + normalized, nil + } + // Add more rules here if needed, e.g., for other country-specific lengths without '+' + } + + // Basic validation: check if it's all digits now and has a reasonable length + // This is a very basic check. Real-world validation is much more complex. + if len(normalized) < 7 || len(normalized) > 15 { // Arbitrary min/max lengths + return "", fmt.Errorf("phone number '%s' has invalid length after normalization", normalized) + } + + return normalized, nil +} + +var fetchContactsFromGoogleGroupFunc = func(authToken string, groupName string, forUserLog string) ([]map[string]string, error) { + log.Info().Str("user_id", forUserLog).Str("groupName", groupName).Msg("Starting to fetch contacts from Google Group (REAL IMPLEMENTATION)") + + httpClient := http.DefaultClient + + var targetGroupResourceName string + var pageToken string + processedGroups := 0 + + log.Debug().Str("user_id", forUserLog).Msg("Fetching contact groups from Google People API") + for { + groupsURL := "https://people.googleapis.com/v1/contactGroups?pageSize=100" + if pageToken != "" { + groupsURL += "&pageToken=" + pageToken + } + + req, err := http.NewRequest("GET", groupsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for contact groups: %w", err) + } + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + log.Error().Err(err).Str("user_id", forUserLog).Str("url", groupsURL).Msg("Failed HTTP request to fetch contact groups") + return nil, fmt.Errorf("failed to execute request for contact groups: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + log.Error().Err(readErr).Str("user_id", forUserLog).Int("status_code", resp.StatusCode).Msg("Failed to read error response body from Google (contact groups)") + return nil, fmt.Errorf("error fetching contact groups, status: %s, failed to read error body", resp.Status) + } + var googleErr GoogleApiError + if json.Unmarshal(bodyBytes, &googleErr) == nil && googleErr.Error.Message != "" { + log.Error().Str("user_id", forUserLog).Int("status_code", resp.StatusCode).Str("google_error_status", googleErr.Error.Status).Str("google_error_message", googleErr.Error.Message).Msg("Google API error fetching contact groups") + return nil, fmt.Errorf("google API error fetching contact groups: %s (Status: %s)", googleErr.Error.Message, googleErr.Error.Status) + } + log.Error().Str("user_id", forUserLog).Int("status_code", resp.StatusCode).Str("response_body", string(bodyBytes)).Msg("Non-OK HTTP status fetching contact groups from Google") + return nil, fmt.Errorf("error fetching contact groups, status: %s, body: %s", resp.Status, string(bodyBytes)) + } + + var groupListResp GoogleContactGroupListResponse + if err := json.NewDecoder(resp.Body).Decode(&groupListResp); err != nil { + return nil, fmt.Errorf("failed to decode contact groups response: %w", err) + } + + for _, group := range groupListResp.ContactGroups { + processedGroups++ + if strings.EqualFold(group.Name, groupName) || strings.EqualFold(group.FormattedName, groupName) { + targetGroupResourceName = group.ResourceName + log.Info().Str("user_id", forUserLog).Str("groupName", groupName).Str("resourceName", targetGroupResourceName).Msg("Found target contact group") + break + } + } + + if targetGroupResourceName != "" { + break + } + pageToken = groupListResp.NextPageToken + if pageToken == "" { + break + } + } + log.Debug().Str("user_id", forUserLog).Int("total_groups_checked", processedGroups).Msg("Finished checking contact groups") + + + if targetGroupResourceName == "" { + return nil, fmt.Errorf("contact group '%s' not found for user %s", groupName, forUserLog) + } + + var contactsResult []map[string]string + pageToken = "" + processedConnections := 0 + + log.Debug().Str("user_id", forUserLog).Str("groupResourceName", targetGroupResourceName).Msg("Fetching connections for the target group from Google People API") + for { + connectionsURL := "https://people.googleapis.com/v1/people/me/connections?personFields=names,phoneNumbers,memberships&pageSize=100" + if pageToken != "" { + connectionsURL += "&pageToken=" + pageToken + } + + req, err := http.NewRequest("GET", connectionsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for connections: %w", err) + } + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + log.Error().Err(err).Str("user_id", forUserLog).Str("url", connectionsURL).Msg("Failed HTTP request to fetch connections") + return nil, fmt.Errorf("failed to execute request for connections: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + log.Error().Err(readErr).Str("user_id", forUserLog).Int("status_code", resp.StatusCode).Msg("Failed to read error response body from Google (connections)") + return nil, fmt.Errorf("error fetching connections, status: %s, failed to read error body", resp.Status) + } + var googleErr GoogleApiError + if json.Unmarshal(bodyBytes, &googleErr) == nil && googleErr.Error.Message != "" { + log.Error().Str("user_id", forUserLog).Int("status_code", resp.StatusCode).Str("google_error_status", googleErr.Error.Status).Str("google_error_message", googleErr.Error.Message).Msg("Google API error fetching connections") + return nil, fmt.Errorf("google API error fetching connections: %s (Status: %s)", googleErr.Error.Message, googleErr.Error.Status) + } + log.Error().Str("user_id", forUserLog).Int("status_code", resp.StatusCode).Str("response_body", string(bodyBytes)).Msg("Non-OK HTTP status fetching connections from Google") + return nil, fmt.Errorf("error fetching connections, status: %s, body: %s", resp.Status, string(bodyBytes)) + } + + var connListResp GoogleConnectionsListResponse + if err := json.NewDecoder(resp.Body).Decode(&connListResp); err != nil { + return nil, fmt.Errorf("failed to decode connections response: %w", err) + } + + log.Debug().Str("user_id", forUserLog).Int("connections_in_page", len(connListResp.Connections)).Msg("Processing connections page") + + for _, person := range connListResp.Connections { + processedConnections++ + isMember := false + for _, membership := range person.Memberships { + if membership.ContactGroupMembership.ContactGroupResourceName == targetGroupResourceName { + isMember = true + break + } + } + + if isMember { + var displayName string + if len(person.Names) > 0 { + displayName = person.Names[0].DisplayName + } + + var phoneNumber string + if len(person.PhoneNumbers) > 0 { + if person.PhoneNumbers[0].CanonicalForm != "" { + phoneNumber = person.PhoneNumbers[0].CanonicalForm + } else { + phoneNumber = person.PhoneNumbers[0].Value + } + } + + if strings.TrimSpace(phoneNumber) != "" { + contactsResult = append(contactsResult, map[string]string{"name": displayName, "phoneNumber": phoneNumber}) + log.Debug().Str("user_id", forUserLog).Str("contactName", displayName).Str("phoneNumber", phoneNumber).Msg("Added contact from group") + } else { + log.Warn().Str("user_id", forUserLog).Str("contactName", displayName).Msg("Contact in group has no phone number, skipping.") + } + } + } + + pageToken = connListResp.NextPageToken + if pageToken == "" { + break + } + } + log.Info().Str("user_id", forUserLog).Int("total_connections_checked", processedConnections).Int("contacts_added_from_group", len(contactsResult)).Msg("Finished fetching and filtering connections") + + return contactsResult, nil +} + +// SetGoogleContactsAuthToken handles storing the Google Contacts API authentication token for a user. +func (s *server) SetGoogleContactsAuthToken() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req AuthTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + if strings.TrimSpace(req.AuthToken) == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing AuthToken in Payload")) + return + } + + query := "UPDATE users SET google_contacts_auth_token = $1 WHERE id = $2" + if s.db.DriverName() == "sqlite" { + query = "UPDATE users SET google_contacts_auth_token = ? WHERE id = ?" + } + + result, err := s.db.Exec(query, req.AuthToken, txtid) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to update google_contacts_auth_token") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to store auth token")) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to check affected rows for google_contacts_auth_token update") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to confirm token storage")) + return + } + if rowsAffected == 0 { + log.Warn().Str("user_id", txtid).Msg("No user found to update google_contacts_auth_token, though middleware should ensure user exists") + s.Respond(w, r, http.StatusNotFound, errors.New("User not found to store token")) + return + } + + response := map[string]string{"detail": "Auth token stored successfully"} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// AddContactGroupToMode handles adding contacts from a Google Contact Group to a mode. +func (s *server) AddContactGroupToMode() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req ContactGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + if strings.TrimSpace(req.ModeName) == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing ModeName in Payload")) + return + } + if strings.TrimSpace(req.GroupName) == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing GroupName in Payload")) + return + } + if strings.TrimSpace(req.Message) == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Message in Payload")) + return + } + + modeName := strings.ToLower(req.ModeName) + if !isValidModeName(modeName) { // isValidModeName will be in autoreply_handlers.go + s.Respond(w, r, http.StatusBadRequest, errors.New("Invalid ModeName: must be alphanumeric")) + return + } + + var googleAuthToken sql.NullString + tokenQuery := "SELECT google_contacts_auth_token FROM users WHERE id = $1" + if s.db.DriverName() == "sqlite" { + tokenQuery = "SELECT google_contacts_auth_token FROM users WHERE id = ?" + } + err := s.db.Get(&googleAuthToken, tokenQuery, txtid) + if err != nil { + if err == sql.ErrNoRows { + log.Error().Str("user_id", txtid).Msg("User not found when trying to fetch Google Auth Token") + s.Respond(w, r, http.StatusNotFound, errors.New("User not found")) + return + } + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to fetch google_contacts_auth_token") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to retrieve auth token information")) + return + } + + if !googleAuthToken.Valid || googleAuthToken.String == "" { + s.Respond(w, r, http.StatusForbidden, errors.New("Google Contacts API token not configured. Please use /autoreply/contactgroupauth.")) + return + } + + contacts, err := fetchContactsFromGoogleGroupFunc(googleAuthToken.String, req.GroupName, txtid) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("groupName", req.GroupName).Msg("Error from fetchContactsFromGoogleGroup in AddContactGroupToMode") + if strings.Contains(err.Error(), "UNAUTHENTICATED") || strings.Contains(err.Error(), "PERMISSION_DENIED") { + s.Respond(w, r, http.StatusForbidden, errors.New("Failed to authenticate with Google Contacts API. Please check your token or re-authenticate via /autoreply/contactgroupauth.")) + } else if strings.Contains(err.Error(), "contact group '"+req.GroupName+"' not found") { + s.Respond(w, r, http.StatusNotFound, errors.New(fmt.Sprintf("Specified contact group '%s' not found.", req.GroupName))) + } else { + s.Respond(w, r, http.StatusInternalServerError, errors.New("Error processing contacts from Google group.")) + } + return + } + + if len(contacts) == 0 { + response := map[string]string{"detail": fmt.Sprintf("No contacts found or processed for group '%s'.", req.GroupName)} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + return + } + + var upsertQuery string + dbType := s.db.DriverName() + if dbType == "postgres" { + upsertQuery = `INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, mode_name, phone_number) + DO UPDATE SET message = EXCLUDED.message;` + } else { + upsertQuery = `INSERT OR REPLACE INTO autoreply_modes (user_id, mode_name, phone_number, message) + VALUES (?, ?, ?, ?);` + } + + var processedCount, skippedCount int + tx, err := s.db.Beginx() + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to begin transaction for AddContactGroupToMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to process contacts")) + return + } + defer tx.Rollback() + + stmt, err := tx.Preparex(upsertQuery) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to prepare statement for inserting mode autoreplies") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to process contacts")) + return + } + defer stmt.Close() + + for _, contact := range contacts { + phoneNumber, ok := contact["phoneNumber"] + if !ok || strings.TrimSpace(phoneNumber) == "" { + log.Warn().Str("user_id", txtid).Str("contact_name", contact["name"]).Msg("Skipping contact due to missing or empty phone number") + skippedCount++ + continue + } + + normalizedPhone, normErr := normalizePhoneNumber(phoneNumber) + if normErr != nil { + log.Warn().Err(normErr).Str("user_id", txtid).Str("original_phone", phoneNumber).Str("contact_name", contact["name"]).Msg("Skipping contact due to phone normalization error") + skippedCount++ + continue + } + + if _, err := stmt.Exec(txtid, modeName, normalizedPhone, req.Message); err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("normalized_phone", normalizedPhone).Msg("Failed to upsert contact into autoreply_modes") + skippedCount++ + continue + } + processedCount++ + } + + if err := tx.Commit(); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to commit transaction for AddContactGroupToMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to save contact group data")) + return + } + + detailMsg := fmt.Sprintf("%d contacts processed and added/updated for mode '%s'. %d contacts skipped.", processedCount, modeName, skippedCount) + response := map[string]string{"detail": detailMsg} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } +} + +// DeleteContactGroupFromMode handles deleting contacts from a Google Contact Group from a mode. +func (s *server) DeleteContactGroupFromMode() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + var req ContactGroupDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) + return + } + + if strings.TrimSpace(req.ModeName) == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing ModeName in Payload")) + return + } + if strings.TrimSpace(req.GroupName) == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("Missing GroupName in Payload")) + return + } + + modeName := strings.ToLower(req.ModeName) + if !isValidModeName(modeName) { // isValidModeName will be in autoreply_handlers.go + s.Respond(w, r, http.StatusBadRequest, errors.New("Invalid ModeName: must be alphanumeric")) + return + } + + var googleAuthToken sql.NullString + tokenQuery := "SELECT google_contacts_auth_token FROM users WHERE id = $1" + if s.db.DriverName() == "sqlite" { + tokenQuery = "SELECT google_contacts_auth_token FROM users WHERE id = ?" + } + err := s.db.Get(&googleAuthToken, tokenQuery, txtid) + if err != nil { + if err == sql.ErrNoRows { + log.Error().Str("user_id", txtid).Msg("User not found when trying to fetch Google Auth Token for delete op") + s.Respond(w, r, http.StatusNotFound, errors.New("User not found")) + return + } + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to fetch google_contacts_auth_token for delete op") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to retrieve auth token information")) + return + } + + if !googleAuthToken.Valid || googleAuthToken.String == "" { + s.Respond(w, r, http.StatusForbidden, errors.New("Google Contacts API token not configured. Please use /autoreply/contactgroupauth.")) + return + } + + contacts, err := fetchContactsFromGoogleGroupFunc(googleAuthToken.String, req.GroupName, txtid) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("groupName", req.GroupName).Msg("Error from fetchContactsFromGoogleGroup in DeleteContactGroupFromMode") + if strings.Contains(err.Error(), "UNAUTHENTICATED") || strings.Contains(err.Error(), "PERMISSION_DENIED") { + s.Respond(w, r, http.StatusForbidden, errors.New("Failed to authenticate with Google Contacts API. Please check your token or re-authenticate via /autoreply/contactgroupauth.")) + } else if strings.Contains(err.Error(), "contact group '"+req.GroupName+"' not found") { + s.Respond(w, r, http.StatusNotFound, errors.New(fmt.Sprintf("Specified contact group '%s' not found.", req.GroupName))) + } else { + s.Respond(w, r, http.StatusInternalServerError, errors.New("Error processing contacts from Google group for deletion.")) + } + return + } + + if len(contacts) == 0 { + response := map[string]string{"detail": fmt.Sprintf("No contacts found in group '%s' to process for deletion.", req.GroupName)} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + return + } + + deleteQuery := "DELETE FROM autoreply_modes WHERE user_id = $1 AND mode_name = $2 AND phone_number = $3" + if s.db.DriverName() == "sqlite" { + deleteQuery = "DELETE FROM autoreply_modes WHERE user_id = ? AND mode_name = ? AND phone_number = ?" + } + + var processedCount, skippedCount, actuallyDeletedCount int + + tx, err := s.db.Beginx() + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to begin transaction for DeleteContactGroupFromMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to process contacts for deletion")) + return + } + defer tx.Rollback() + + stmt, err := tx.Preparex(deleteQuery) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to prepare statement for deleting mode autoreplies") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to process contacts for deletion")) + return + } + defer stmt.Close() + + for _, contact := range contacts { + phoneNumber, ok := contact["phoneNumber"] + if !ok || strings.TrimSpace(phoneNumber) == "" { + log.Warn().Str("user_id", txtid).Str("contact_name", contact["name"]).Msg("Skipping contact for deletion due to missing or empty phone number") + skippedCount++ + continue + } + + normalizedPhone, normErr := normalizePhoneNumber(phoneNumber) + if normErr != nil { + log.Warn().Err(normErr).Str("user_id", txtid).Str("original_phone", phoneNumber).Str("contact_name", contact["name"]).Msg("Skipping contact for deletion due to phone normalization error") + skippedCount++ + continue + } + + result, err := stmt.Exec(txtid, modeName, normalizedPhone) + if err != nil { + log.Error().Err(err).Str("user_id", txtid).Str("normalized_phone", normalizedPhone).Msg("Failed to delete contact from autoreply_modes") + skippedCount++ + continue + } + processedCount++ + rowsAffected, _ := result.RowsAffected() + if rowsAffected > 0 { + actuallyDeletedCount++ + } + } + + if err := tx.Commit(); err != nil { + log.Error().Err(err).Str("user_id", txtid).Msg("Failed to commit transaction for DeleteContactGroupFromMode") + s.Respond(w, r, http.StatusInternalServerError, errors.New("Failed to save changes for contact group deletion")) + return + } + + detailMsg := fmt.Sprintf("%d contacts from group '%s' processed for deletion from mode '%s'. %d entries actually deleted. %d contacts skipped.", processedCount, req.GroupName, modeName, actuallyDeletedCount, skippedCount) + response := map[string]string{"detail": detailMsg} + responseJson, _ := json.Marshal(response) + s.Respond(w, r, http.StatusOK, string(responseJson)) + } } -var messageTypes = []string{"Message", "ReadReceipt", "Presence", "HistorySync", "ChatPresence", "All"} func (s *server) authadmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -64,7 +1315,6 @@ func (s *server) authalice(next http.Handler) http.Handler { proxy_url := "" qrcode := "" - // Get token from headers or uri parameters token := r.Header.Get("token") if token == "" { token = strings.Join(r.URL.Query()["token"], "") @@ -73,7 +1323,6 @@ func (s *server) authalice(next http.Handler) http.Handler { myuserinfo, found := userinfocache.Get(token) if !found { log.Info().Msg("Looking for user information in DB") - // Checks DB from matching user and store user values in context rows, err := s.db.Query("SELECT id,name,webhook,jid,events,proxy_url,qrcode FROM users WHERE token=$1 LIMIT 1", token) if err != nil { s.Respond(w, r, http.StatusInternalServerError, err) @@ -117,21 +1366,18 @@ func (s *server) authalice(next http.Handler) http.Handler { // Connects to Whatsapp Servers func (s *server) Connect() http.HandlerFunc { - type connectStruct struct { Subscribe []string Immediate bool } return func(w http.ResponseWriter, r *http.Request) { - webhook := r.Context().Value("userinfo").(Values).Get("Webhook") jid := r.Context().Value("userinfo").(Values).Get("Jid") txtid := r.Context().Value("userinfo").(Values).Get("Id") token := r.Context().Value("userinfo").(Values).Get("Token") eventstring := "" - // Decodes request BODY looking for events to subscribe decoder := json.NewDecoder(r.Body) var t connectStruct err := decoder.Decode(&t) @@ -144,19 +1390,18 @@ func (s *server) Connect() http.HandlerFunc { s.Respond(w, r, http.StatusInternalServerError, errors.New("Already Connected")) return } else { - var subscribedEvents []string if len(t.Subscribe) < 1 { - if !Find(subscribedEvents, "All") { + if _, found := Find(subscribedEvents, "All"); !found { subscribedEvents = append(subscribedEvents, "All") } } else { for _, arg := range t.Subscribe { - if !Find(messageTypes, arg) { + if _, found := Find(supportedEventTypes, arg); !found { log.Warn().Str("Type", arg).Msg("Message type discarded") continue } - if !Find(subscribedEvents, arg) { + if _, found := Find(subscribedEvents, arg); !found { subscribedEvents = append(subscribedEvents, arg) } } @@ -215,7 +1460,6 @@ func (s *server) Disconnect() http.HandlerFunc { return } if clientManager.GetWhatsmeowClient(txtid).IsConnected() == true { - //if clientManager.GetWhatsmeowClient(txtid).IsLoggedIn() == true { log.Info().Str("jid", jid).Msg("Disconnection successfull") _, err := s.db.Exec("UPDATE users SET connected=0,events=$1 WHERE id=$2", "", txtid) if err != nil { @@ -228,7 +1472,7 @@ func (s *server) Disconnect() http.HandlerFunc { response := map[string]interface{}{"Details": "Disconnected"} responseJson, err := json.Marshal(response) - clientManager.DeleteWhatsmeowClient(txtid) // mameluco + clientManager.DeleteWhatsmeowClient(txtid) killchannel[txtid] <- true if err != nil { @@ -237,11 +1481,6 @@ func (s *server) Disconnect() http.HandlerFunc { s.Respond(w, r, http.StatusOK, string(responseJson)) } return - //} else { - // log.Warn().Str("jid", jid).Msg("Ignoring disconnect as it was not connected") - // s.Respond(w, r, http.StatusInternalServerError, errors.New("Cannot disconnect because it is not logged in")) - // return - //} } else { log.Warn().Str("jid", jid).Msg("Ignoring disconnect as it was not connected") s.Respond(w, r, http.StatusInternalServerError, errors.New("Cannot disconnect because it is not logged in")) @@ -296,14 +1535,12 @@ func (s *server) DeleteWebhook() http.HandlerFunc { txtid := r.Context().Value("userinfo").(Values).Get("Id") token := r.Context().Value("userinfo").(Values).Get("Token") - // Update the database to remove the webhook and clear events _, err := s.db.Exec("UPDATE users SET webhook='', events='' WHERE id=$1", txtid) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Could not delete webhook: %v", err))) return } - // Update the user info cache v := updateUserInfo(r.Context().Value("userinfo"), "Webhook", "") v = updateUserInfo(v, "Events", "") userinfocache.Set(token, v, cache.NoExpiration) @@ -342,7 +1579,7 @@ func (s *server) UpdateWebhook() http.HandlerFunc { var eventstring string var validEvents []string for _, event := range t.Events { - if !Find(messageTypes, event) { + if _, found := Find(supportedEventTypes, event); !found { log.Warn().Str("Type", event).Msg("Message type discarded") continue } @@ -361,7 +1598,6 @@ func (s *server) UpdateWebhook() http.HandlerFunc { if len(t.Events) > 0 { _, err = s.db.Exec("UPDATE users SET webhook=$1, events=$2 WHERE id=$3", webhook, eventstring, txtid) } else { - // Update only webhook _, err = s.db.Exec("UPDATE users SET webhook=$1 WHERE id=$2", webhook, txtid) } @@ -403,13 +1639,11 @@ func (s *server) SetWebhook() http.HandlerFunc { } webhook := t.WebhookURL - - // If events are provided, validate them var eventstring string if len(t.Events) > 0 { var validEvents []string for _, event := range t.Events { - if !Find(messageTypes, event) { + if _, found := Find(supportedEventTypes, event); !found { log.Warn().Str("Type", event).Msg("Message type discarded") continue } @@ -419,11 +1653,8 @@ func (s *server) SetWebhook() http.HandlerFunc { if eventstring == "," || eventstring == "" { eventstring = "All" } - - // Update both webhook and events _, err = s.db.Exec("UPDATE users SET webhook=$1, events=$2 WHERE id=$3", webhook, eventstring, txtid) } else { - // Update only webhook _, err = s.db.Exec("UPDATE users SET webhook=$1 WHERE id=$2", webhook, txtid) } @@ -449,7 +1680,6 @@ func (s *server) SetWebhook() http.HandlerFunc { // Gets QR code encoded in Base64 func (s *server) GetQR() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") code := "" @@ -500,7 +1730,6 @@ func (s *server) GetQR() http.HandlerFunc { // Logs out device from Whatsapp (requires to scan QR next time) func (s *server) Logout() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") jid := r.Context().Value("userinfo").(Values).Get("Jid") @@ -546,13 +1775,10 @@ func (s *server) Logout() http.HandlerFunc { // Pair by Phone. Retrieves the code to pair by phone number instead of QR func (s *server) PairPhone() http.HandlerFunc { - type pairStruct struct { Phone string } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -600,12 +1826,8 @@ func (s *server) PairPhone() http.HandlerFunc { // Gets Connected and LoggedIn Status func (s *server) GetStatus() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userInfo := r.Context().Value("userinfo").(Values) - - // Log all userinfo values log.Info(). Str("Id", userInfo.Get("Id")). Str("Jid", userInfo.Get("Jid")). @@ -617,16 +1839,8 @@ func (s *server) GetStatus() http.HandlerFunc { Msg("User info values") log.Info().Str("Name", userInfo.Get("Name")).Msg("User name") - txtid := userInfo.Get("Id") - /* - if clientManager.GetWhatsmeowClient(txtid) == nil { - s.Respond(w, r, http.StatusInternalServerError, errors.New("No session")) - return - } - */ - isConnected := clientManager.GetWhatsmeowClient(txtid).IsConnected() isLoggedIn := clientManager.GetWhatsmeowClient(txtid).IsLoggedIn() @@ -654,7 +1868,6 @@ func (s *server) GetStatus() http.HandlerFunc { // Sends a document/attachment message func (s *server) SendDocument() http.HandlerFunc { - type documentStruct struct { Caption string Phone string @@ -664,9 +1877,7 @@ func (s *server) SendDocument() http.HandlerFunc { MimeType string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") msgid := "" var resp whatsmeow.SendResponse @@ -684,38 +1895,31 @@ func (s *server) SendDocument() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Document == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Document in Payload")) return } - if t.FileName == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing FileName in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - var uploaded whatsmeow.UploadResponse var filedata []byte - if t.Document[0:29] == "data:application/octet-stream" { var dataURL, err = dataurl.DecodeString(t.Document) if err != nil { @@ -733,7 +1937,6 @@ func (s *server) SendDocument() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Document data should start with \"data:application/octet-stream;base64,\"")) return } - msg := &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ URL: proto.String(uploaded.URL), FileName: &t.FileName, @@ -750,7 +1953,6 @@ func (s *server) SendDocument() http.HandlerFunc { FileLength: proto.Uint64(uint64(len(filedata))), Caption: proto.String(t.Caption), }} - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -764,13 +1966,11 @@ func (s *server) SendDocument() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -785,7 +1985,6 @@ func (s *server) SendDocument() http.HandlerFunc { // Sends an audio message func (s *server) SendAudio() http.HandlerFunc { - type audioStruct struct { Phone string Audio string @@ -793,9 +1992,7 @@ func (s *server) SendAudio() http.HandlerFunc { Id string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") msgid := "" var resp whatsmeow.SendResponse @@ -812,33 +2009,27 @@ func (s *server) SendAudio() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Audio == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Audio in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - var uploaded whatsmeow.UploadResponse var filedata []byte - if t.Audio[0:14] == "data:audio/ogg" { var dataURL, err = dataurl.DecodeString(t.Audio) if err != nil { @@ -856,22 +2047,18 @@ func (s *server) SendAudio() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Audio data should start with \"data:audio/ogg;base64,\"")) return } - ptt := true mime := "audio/ogg; codecs=opus" - msg := &waE2E.Message{AudioMessage: &waE2E.AudioMessage{ URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), MediaKey: uploaded.MediaKey, - //Mimetype: proto.String(http.DetectContentType(filedata)), Mimetype: &mime, FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(filedata))), PTT: &ptt, }} - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -885,13 +2072,11 @@ func (s *server) SendAudio() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -906,7 +2091,6 @@ func (s *server) SendAudio() http.HandlerFunc { // Sends an Image message func (s *server) SendImage() http.HandlerFunc { - type imageStruct struct { Phone string Image string @@ -915,9 +2099,7 @@ func (s *server) SendImage() http.HandlerFunc { MimeType string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") msgid := "" var resp whatsmeow.SendResponse @@ -934,34 +2116,28 @@ func (s *server) SendImage() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Image == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Image in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - var uploaded whatsmeow.UploadResponse var filedata []byte var thumbnailBytes []byte - if t.Image[0:10] == "data:image" { var dataURL, err = dataurl.DecodeString(t.Image) if err != nil { @@ -975,42 +2151,32 @@ func (s *server) SendImage() http.HandlerFunc { return } } - - // decode jpeg into image.Image reader := bytes.NewReader(filedata) img, _, err := image.Decode(reader) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Could not decode image for thumbnail preparation: %v", err))) return } - - // resize to width 72 using Lanczos resampling and preserve aspect ratio m := resize.Thumbnail(72, 72, img, resize.Lanczos3) - tmpFile, err := os.CreateTemp("", "resized-*.jpg") if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Could not create temp file for thumbnail: %v", err))) return } defer tmpFile.Close() - - // write new image to file if err := jpeg.Encode(tmpFile, m, nil); err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Failed to encode jpeg: %v", err))) return } - thumbnailBytes, err = os.ReadFile(tmpFile.Name()) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Failed to read %s: %v", tmpFile.Name(), err))) return } - } else { s.Respond(w, r, http.StatusBadRequest, errors.New("Image data should start with \"data:image/png;base64,\"")) return } - msg := &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ Caption: proto.String(t.Caption), URL: proto.String(uploaded.URL), @@ -1027,7 +2193,6 @@ func (s *server) SendImage() http.HandlerFunc { FileLength: proto.Uint64(uint64(len(filedata))), JPEGThumbnail: thumbnailBytes, }} - if t.ContextInfo.StanzaID != nil { if msg.ImageMessage.ContextInfo == nil { msg.ImageMessage.ContextInfo = &waE2E.ContextInfo{ @@ -1037,17 +2202,14 @@ func (s *server) SendImage() http.HandlerFunc { } } } - if t.ContextInfo.MentionedJID != nil { msg.ImageMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1062,7 +2224,6 @@ func (s *server) SendImage() http.HandlerFunc { // Sends Sticker message func (s *server) SendSticker() http.HandlerFunc { - type stickerStruct struct { Phone string Sticker string @@ -1071,9 +2232,7 @@ func (s *server) SendSticker() http.HandlerFunc { MimeType string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") msgid := "" var resp whatsmeow.SendResponse @@ -1090,33 +2249,27 @@ func (s *server) SendSticker() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Sticker == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Sticker in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - var uploaded whatsmeow.UploadResponse var filedata []byte - if t.Sticker[0:4] == "data" { var dataURL, err = dataurl.DecodeString(t.Sticker) if err != nil { @@ -1134,7 +2287,6 @@ func (s *server) SendSticker() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Data should start with \"data:mime/type;base64,\"")) return } - msg := &waE2E.Message{StickerMessage: &waE2E.StickerMessage{ URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), @@ -1150,7 +2302,6 @@ func (s *server) SendSticker() http.HandlerFunc { FileLength: proto.Uint64(uint64(len(filedata))), PngThumbnail: t.PngThumbnail, }} - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -1164,13 +2315,11 @@ func (s *server) SendSticker() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1185,7 +2334,6 @@ func (s *server) SendSticker() http.HandlerFunc { // Sends Video message func (s *server) SendVideo() http.HandlerFunc { - type imageStruct struct { Phone string Video string @@ -1195,9 +2343,7 @@ func (s *server) SendVideo() http.HandlerFunc { MimeType string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") msgid := "" var resp whatsmeow.SendResponse @@ -1214,33 +2360,27 @@ func (s *server) SendVideo() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Video == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Video in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - var uploaded whatsmeow.UploadResponse var filedata []byte - if t.Video[0:4] == "data" { var dataURL, err = dataurl.DecodeString(t.Video) if err != nil { @@ -1258,7 +2398,6 @@ func (s *server) SendVideo() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Data should start with \"data:mime/type;base64,\"")) return } - msg := &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ Caption: proto.String(t.Caption), URL: proto.String(uploaded.URL), @@ -1275,7 +2414,6 @@ func (s *server) SendVideo() http.HandlerFunc { FileLength: proto.Uint64(uint64(len(filedata))), JPEGThumbnail: t.JPEGThumbnail, }} - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -1289,13 +2427,11 @@ func (s *server) SendVideo() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1310,7 +2446,6 @@ func (s *server) SendVideo() http.HandlerFunc { // Sends Contact func (s *server) SendContact() http.HandlerFunc { - type contactStruct struct { Phone string Id string @@ -1318,9 +2453,7 @@ func (s *server) SendContact() http.HandlerFunc { Vcard string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -1350,25 +2483,21 @@ func (s *server) SendContact() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Vcard in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - msg := &waE2E.Message{ContactMessage: &waE2E.ContactMessage{ DisplayName: &t.Name, Vcard: &t.Vcard, }} - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -1382,13 +2511,11 @@ func (s *server) SendContact() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1403,7 +2530,6 @@ func (s *server) SendContact() http.HandlerFunc { // Sends location func (s *server) SendLocation() http.HandlerFunc { - type locationStruct struct { Phone string Id string @@ -1412,9 +2538,7 @@ func (s *server) SendLocation() http.HandlerFunc { Longitude float64 ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -1444,26 +2568,22 @@ func (s *server) SendLocation() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Longitude in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - msg := &waE2E.Message{LocationMessage: &waE2E.LocationMessage{ DegreesLatitude: &t.Latitude, DegreesLongitude: &t.Longitude, Name: &t.Name, }} - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -1477,13 +2597,11 @@ func (s *server) SendLocation() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1499,7 +2617,6 @@ func (s *server) SendLocation() http.HandlerFunc { // Sends Buttons (not implemented, does not work) func (s *server) SendButtons() http.HandlerFunc { - type buttonStruct struct { ButtonId string ButtonText string @@ -1510,9 +2627,7 @@ func (s *server) SendButtons() http.HandlerFunc { Buttons []buttonStruct Id string } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -1530,17 +2645,14 @@ func (s *server) SendButtons() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Title == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Title in Payload")) return } - if len(t.Buttons) < 1 { s.Respond(w, r, http.StatusBadRequest, errors.New("missing Buttons in Payload")) return @@ -1549,21 +2661,17 @@ func (s *server) SendButtons() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("buttons cant more than 3")) return } - recipient, ok := parseJID(t.Phone) if !ok { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not parse Phone")) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - var buttons []*waE2E.ButtonsMessage_Button - for _, item := range t.Buttons { buttons = append(buttons, &waE2E.ButtonsMessage_Button{ ButtonID: proto.String(item.ButtonId), @@ -1572,13 +2680,11 @@ func (s *server) SendButtons() http.HandlerFunc { NativeFlowInfo: &waE2E.ButtonsMessage_Button_NativeFlowInfo{}, }) } - msg2 := &waE2E.ButtonsMessage{ ContentText: proto.String(t.Title), HeaderType: waE2E.ButtonsMessage_EMPTY.Enum(), Buttons: buttons, } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, &waE2E.Message{ViewOnceMessage: &waE2E.FutureProofMessage{ Message: &waE2E.Message{ ButtonsMessage: msg2, @@ -1588,7 +2694,6 @@ func (s *server) SendButtons() http.HandlerFunc { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1604,18 +2709,15 @@ func (s *server) SendButtons() http.HandlerFunc { // SendList // https://github.com/tulir/whatsmeow/issues/305 func (s *server) SendList() http.HandlerFunc { - type rowsStruct struct { RowId string Title string Description string } - type sectionsStruct struct { Title string Rows []rowsStruct } - type listStruct struct { Phone string Title string @@ -1625,9 +2727,7 @@ func (s *server) SendList() http.HandlerFunc { Sections []sectionsStruct Id string } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -1648,27 +2748,22 @@ func (s *server) SendList() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("missing Phone in Payload")) return } - if t.Title == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("missing Title in Payload")) return } - if t.Description == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("missing Description in Payload")) return } - if t.ButtonText == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("missing ButtonText in Payload")) return } - if len(t.Sections) < 1 { s.Respond(w, r, http.StatusBadRequest, errors.New("missing Sections in Payload")) return @@ -1678,15 +2773,12 @@ func (s *server) SendList() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("could not parse Phone")) return } - if t.Id == "" { msgid = whatsmeow.GenerateMessageID() } else { msgid = t.Id } - var sections []*waE2E.ListMessage_Section - for _, item := range t.Sections { var rows []*waE2E.ListMessage_Row id := 1 @@ -1703,7 +2795,6 @@ func (s *server) SendList() http.HandlerFunc { Description: proto.String(row.Description), }) } - sections = append(sections, &waE2E.ListMessage_Section{ Title: proto.String(item.Title), Rows: rows, @@ -1717,7 +2808,6 @@ func (s *server) SendList() http.HandlerFunc { Sections: sections, FooterText: proto.String(t.FooterText), } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, &waE2E.Message{ ViewOnceMessage: &waE2E.FutureProofMessage{ Message: &waE2E.Message{ @@ -1728,7 +2818,6 @@ func (s *server) SendList() http.HandlerFunc { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1743,16 +2832,13 @@ func (s *server) SendList() http.HandlerFunc { // Sends a regular text message func (s *server) SendMessage() http.HandlerFunc { - type textStruct struct { Phone string Body string Id string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -1770,36 +2856,30 @@ func (s *server) SendMessage() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Body == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Body in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { msgid = clientManager.GetWhatsmeowClient(txtid).GenerateMessageID() } else { msgid = t.Id } - msg := &waE2E.Message{ ExtendedTextMessage: &waE2E.ExtendedTextMessage{ Text: &t.Body, }, } - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -1813,13 +2893,11 @@ func (s *server) SendMessage() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1828,19 +2906,17 @@ func (s *server) SendMessage() http.HandlerFunc { } else { s.Respond(w, r, http.StatusOK, string(responseJson)) } - return } } func (s *server) SendPoll() http.HandlerFunc { type pollRequest struct { - Group string `json:"group"` // The recipient's group id (120363313346913103@g.us) - Header string `json:"header"` // The poll's headline text - Options []string `json:"options"` // The list of poll options + Group string `json:"group"` + Header string `json:"header"` + Options []string `json:"options"` Id string } - return func(w http.ResponseWriter, r *http.Request) { txtid := r.Context().Value("userinfo").(Values).Get("Id") @@ -1859,43 +2935,35 @@ func (s *server) SendPoll() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) return } - if req.Group == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Grouop in payload")) return } - if req.Header == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Header in payload")) return } - if len(req.Options) < 2 { s.Respond(w, r, http.StatusBadRequest, errors.New("At least 2 options are required")) return } - if req.Id == "" { msgid = clientManager.GetWhatsmeowClient(txtid).GenerateMessageID() } else { msgid = req.Id } - recipient, err := validateMessageFields(req.Group, nil, nil) if err != nil { s.Respond(w, r, http.StatusBadRequest, err) return } - pollMessage := clientManager.GetWhatsmeowClient(txtid).BuildPollCreation(req.Header, req.Options, 1) resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, pollMessage, whatsmeow.SendRequestExtra{ID: msgid}) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Failed to send poll: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Poll sent") - response := map[string]interface{}{"Details": "Poll sent successfully", "Id": msgid} responseJson, err := json.Marshal(response) if err != nil { @@ -1908,14 +2976,11 @@ func (s *server) SendPoll() http.HandlerFunc { // Delete message func (s *server) DeleteMessage() http.HandlerFunc { - type textStruct struct { Phone string Id string } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -1933,31 +2998,25 @@ func (s *server) DeleteMessage() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Id == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Id in Payload")) return } - msgid = t.Id - recipient, ok := parseJID(t.Phone) if !ok { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not parse Phone")) return } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, clientManager.GetWhatsmeowClient(txtid).BuildRevoke(recipient, types.EmptyJID, msgid)) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%v", resp.Timestamp)).Str("id", msgid).Msg("Message deleted") response := map[string]interface{}{"Details": "Deleted", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -1966,23 +3025,19 @@ func (s *server) DeleteMessage() http.HandlerFunc { } else { s.Respond(w, r, http.StatusOK, string(responseJson)) } - return } } // Sends a edit text message func (s *server) SendEditMessage() http.HandlerFunc { - type editStruct struct { Phone string Body string Id string ContextInfo waE2E.ContextInfo } - return func(w http.ResponseWriter, r *http.Request) { - txtid := r.Context().Value("userinfo").(Values).Get("Id") if clientManager.GetWhatsmeowClient(txtid) == nil { @@ -2000,37 +3055,31 @@ func (s *server) SendEditMessage() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) return } - if t.Phone == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - if t.Body == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Body in Payload")) return } - recipient, err := validateMessageFields(t.Phone, t.ContextInfo.StanzaID, t.ContextInfo.Participant) if err != nil { log.Error().Msg(fmt.Sprintf("%s", err)) s.Respond(w, r, http.StatusBadRequest, err) return } - if t.Id == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Id in Payload")) return } else { msgid = t.Id } - msg := &waE2E.Message{ ExtendedTextMessage: &waE2E.ExtendedTextMessage{ Text: &t.Body, }, } - if t.ContextInfo.StanzaID != nil { msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ StanzaID: proto.String(*t.ContextInfo.StanzaID), @@ -2044,13 +3093,11 @@ func (s *server) SendEditMessage() http.HandlerFunc { } msg.ExtendedTextMessage.ContextInfo.MentionedJID = t.ContextInfo.MentionedJID } - resp, err = clientManager.GetWhatsmeowClient(txtid).SendMessage(context.Background(), recipient, clientManager.GetWhatsmeowClient(txtid).BuildEdit(recipient, msgid, msg)) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("Error sending edit message: %v", err))) return } - log.Info().Str("timestamp", fmt.Sprintf("%d", resp.Timestamp)).Str("id", msgid).Msg("Message edit sent") response := map[string]interface{}{"Details": "Sent", "Timestamp": resp.Timestamp, "Id": msgid} responseJson, err := json.Marshal(response) @@ -2059,7 +3106,6 @@ func (s *server) SendEditMessage() http.HandlerFunc { } else { s.Respond(w, r, http.StatusOK, string(responseJson)) } - return } } @@ -3243,7 +4289,6 @@ func (s *server) UpdateGroupParticipants() http.HandlerFunc { type updateGroupParticipantsStruct struct { GroupJID string Phone []string - // Action string // add, remove, promote, demote Action string } @@ -3274,7 +4319,6 @@ func (s *server) UpdateGroupParticipants() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("Missing Phone in Payload")) return } - // parse phone numbers phoneParsed := make([]types.JID, len(t.Phone)) for i, phone := range t.Phone { phoneParsed[i], ok = parseJID(phone) @@ -3289,8 +4333,6 @@ func (s *server) UpdateGroupParticipants() http.HandlerFunc { return } - // parse action - var action whatsmeow.ParticipantChange switch t.Action { case "add": @@ -3416,7 +4458,7 @@ func (s *server) SetGroupPhoto() http.HandlerFunc { var filedata []byte - if t.Image[0:13] == "data:image/jp" { + if t.Image[0:13] == "data:image/jp" { // Should likely be data:image/jpeg or data:image/png var dataURL, err = dataurl.DecodeString(t.Image) if err != nil { s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode base64 encoded data from payload")) @@ -3425,7 +4467,7 @@ func (s *server) SetGroupPhoto() http.HandlerFunc { filedata = dataURL.Data } } else { - s.Respond(w, r, http.StatusBadRequest, errors.New("Image data should start with \"data:image/jpeg;base64,\"")) + s.Respond(w, r, http.StatusBadRequest, errors.New("Image data should start with \"data:image/jpeg;base64,\" or \"data:image/png;base64,\"")) return } @@ -3639,7 +4681,7 @@ func (s *server) SetGroupAnnounce() http.HandlerFunc { var t setGroupAnnounceStruct err := decoder.Decode(&t) if err != nil { - s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode Payload")) + s.Respond(w, r, http.StatusBadRequest, errors.New("Could not decode payload")) return } @@ -3734,22 +4776,10 @@ func (s *server) ListUsers() http.HandlerFunc { var query string var args []interface{} - /* - // Query the database to get the list of users - rows, err := s.db.Queryx("SELECT id, name, token, webhook, jid, qrcode, connected, expiration, events FROM users") - if err != nil { - s.Respond(w, r, http.StatusInternalServerError, errors.New("Problem accessing DB")) - return - } - defer rows.Close() - */ - if hasID { - // Fetch a single user query = "SELECT id, name, token, webhook, jid, qrcode, connected, expiration, proxy_url, events FROM users WHERE id = $1" args = append(args, userID) } else { - // Fetch all users query = "SELECT id, name, token, webhook, jid, qrcode, connected, expiration, proxy_url, events FROM users" } @@ -3760,9 +4790,7 @@ func (s *server) ListUsers() http.HandlerFunc { } defer rows.Close() - // Create a slice to store the user data users := []map[string]interface{}{} - // Iterate over the rows and populate the user data for rows.Next() { var user usersStruct err := rows.StructScan(&user) @@ -3779,7 +4807,6 @@ func (s *server) ListUsers() http.HandlerFunc { isLoggedIn = clientManager.GetWhatsmeowClient(user.Id).IsLoggedIn() } - //"connected": user.Connected.Bool, userMap := map[string]interface{}{ "id": user.Id, "name": user.Name, @@ -3795,13 +4822,11 @@ func (s *server) ListUsers() http.HandlerFunc { } users = append(users, userMap) } - // Check for any error that occurred during iteration if err := rows.Err(); err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New("Problem accessing DB")) return } - // Encode users slice into a JSON string responseJson, err := json.Marshal(users) if err != nil { s.Respond(w, r, http.StatusInternalServerError, err) @@ -3809,15 +4834,12 @@ func (s *server) ListUsers() http.HandlerFunc { } s.Respond(w, r, http.StatusOK, string(responseJson)) - } } func (s *server) AddUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - - // Parse the request body var user struct { Name string `json:"name"` Token string `json:"token"` @@ -3828,26 +4850,23 @@ func (s *server) AddUser() http.HandlerFunc { } if err := json.NewDecoder(r.Body).Decode(&user); err != nil { - s.respondWithJSON(w, http.StatusBadRequest, map[string]interface{}{ + s.Respond(w, r, http.StatusBadRequest, map[string]interface{}{ "code": http.StatusBadRequest, "error": "Invalid request payload", "success": false, }) return } - - // Validate required fields if user.Token == "" { - s.respondWithJSON(w, http.StatusBadRequest, map[string]interface{}{ + s.Respond(w, r, http.StatusBadRequest, map[string]interface{}{ "code": http.StatusBadRequest, "error": "Token is required", "success": false, }) return } - if user.Name == "" { - s.respondWithJSON(w, http.StatusBadRequest, map[string]interface{}{ + s.Respond(w, r, http.StatusBadRequest, map[string]interface{}{ "code": http.StatusBadRequest, "error": "Missing required fields", "success": false, @@ -3855,8 +4874,6 @@ func (s *server) AddUser() http.HandlerFunc { }) return } - - // Set defaults if user.Events == "" { user.Events = "All" } @@ -3866,11 +4883,9 @@ func (s *server) AddUser() http.HandlerFunc { if user.Webhook == "" { user.Webhook = "" } - - // Check for existing user var count int if err := s.db.Get(&count, "SELECT COUNT(*) FROM users WHERE token = $1", user.Token); err != nil { - s.respondWithJSON(w, http.StatusInternalServerError, map[string]interface{}{ + s.Respond(w, r, http.StatusInternalServerError, map[string]interface{}{ "code": http.StatusInternalServerError, "error": "Database error", "success": false, @@ -3878,20 +4893,18 @@ func (s *server) AddUser() http.HandlerFunc { return } if count > 0 { - s.respondWithJSON(w, http.StatusConflict, map[string]interface{}{ + s.Respond(w, r, http.StatusConflict, map[string]interface{}{ "code": http.StatusConflict, "error": "User with this token already exists", "success": false, }) return } - - // Validate events eventList := strings.Split(user.Events, ",") for _, event := range eventList { event = strings.TrimSpace(event) - if !Find(messageTypes, event) { - s.respondWithJSON(w, http.StatusBadRequest, map[string]interface{}{ + if _, found := Find(supportedEventTypes, event); !found { + s.Respond(w, r, http.StatusBadRequest, map[string]interface{}{ "code": http.StatusBadRequest, "error": "Invalid event type", "success": false, @@ -3900,35 +4913,29 @@ func (s *server) AddUser() http.HandlerFunc { return } } - - // Generate ID id, err := GenerateRandomID() if err != nil { log.Error().Err(err).Msg("Failed to generate random ID") - s.respondWithJSON(w, http.StatusInternalServerError, map[string]interface{}{ + s.Respond(w, r, http.StatusInternalServerError, map[string]interface{}{ "code": http.StatusInternalServerError, "error": "Failed to generate user ID", "success": false, }) return } - - // Insert user if _, err = s.db.Exec( "INSERT INTO users (id, name, token, webhook, expiration, events, jid, qrcode, proxy_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", id, user.Name, user.Token, user.Webhook, user.Expiration, user.Events, "", "", user.ProxyURL, ); err != nil { log.Error().Str("error", fmt.Sprintf("%v", err)).Msg("Admin DB Error") - s.respondWithJSON(w, http.StatusInternalServerError, map[string]interface{}{ + s.Respond(w, r, http.StatusInternalServerError, map[string]interface{}{ "code": http.StatusInternalServerError, "error": "Database error", "success": false, }) return } - - // Success response - s.respondWithJSON(w, http.StatusCreated, map[string]interface{}{ + s.Respond(w, r, http.StatusCreated, map[string]interface{}{ "code": http.StatusCreated, "data": map[string]interface{}{ "id": id, @@ -3941,26 +4948,20 @@ func (s *server) AddUser() http.HandlerFunc { func (s *server) DeleteUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - - // Get the user ID from the request URL vars := mux.Vars(r) userID := vars["id"] - - // Delete the user from the database result, err := s.db.Exec("DELETE FROM users WHERE id=$1", userID) if err != nil { - s.respondWithJSON(w, http.StatusInternalServerError, map[string]interface{}{ + s.Respond(w, r, http.StatusInternalServerError, map[string]interface{}{ "code": http.StatusInternalServerError, "error": "Database error", "success": false, }) return } - - // Check if the user was deleted rowsAffected, err := result.RowsAffected() if err != nil { - s.respondWithJSON(w, http.StatusInternalServerError, map[string]interface{}{ + s.Respond(w, r, http.StatusInternalServerError, map[string]interface{}{ "code": http.StatusInternalServerError, "error": "Failed to verify deletion", "success": false, @@ -3968,7 +4969,7 @@ func (s *server) DeleteUser() http.HandlerFunc { return } if rowsAffected == 0 { - s.respondWithJSON(w, http.StatusNotFound, map[string]interface{}{ + s.Respond(w, r, http.StatusNotFound, map[string]interface{}{ "code": http.StatusNotFound, "error": "User not found", "success": false, @@ -3976,7 +4977,7 @@ func (s *server) DeleteUser() http.HandlerFunc { }) return } - s.respondWithJSON(w, http.StatusOK, map[string]interface{}{ + s.Respond(w, r, http.StatusOK, map[string]interface{}{ "code": http.StatusOK, "data": map[string]string{"id": userID}, "success": true, @@ -3988,25 +4989,20 @@ func (s *server) DeleteUser() http.HandlerFunc { func (s *server) DeleteUserComplete() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - vars := mux.Vars(r) id := vars["id"] - - // Validate ID if id == "" { - s.respondWithJSON(w, http.StatusBadRequest, map[string]interface{}{ + s.Respond(w, r, http.StatusBadRequest, map[string]interface{}{ "code": http.StatusBadRequest, "error": "Missing ID", "success": false, }) return } - - // Check if user exists var exists bool err := s.db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists) if err != nil { - s.respondWithJSON(w, http.StatusInternalServerError, map[string]interface{}{ + s.Respond(w, r, http.StatusInternalServerError, map[string]interface{}{ "code": http.StatusInternalServerError, "error": "Database error", "success": false, @@ -4015,7 +5011,7 @@ func (s *server) DeleteUserComplete() http.HandlerFunc { return } if !exists { - s.respondWithJSON(w, http.StatusNotFound, map[string]interface{}{ + s.Respond(w, r, http.StatusNotFound, map[string]interface{}{ "code": http.StatusNotFound, "error": "User not found", "success": false, @@ -4023,16 +5019,11 @@ func (s *server) DeleteUserComplete() http.HandlerFunc { }) return } - - // Get user info before deletion var uname, jid, token string err = s.db.QueryRow("SELECT name, jid, token FROM users WHERE id = $1", id).Scan(&uname, &jid, &token) if err != nil { log.Error().Err(err).Str("id", id).Msg("Problem retrieving user information") - // Continue anyway since we have the ID } - - // 1. Logout and disconnect instance if client := clientManager.GetWhatsmeowClient(id); client != nil { if client.IsConnected() { log.Info().Str("id", id).Msg("Logging out user") @@ -4041,11 +5032,9 @@ func (s *server) DeleteUserComplete() http.HandlerFunc { log.Info().Str("id", id).Msg("Disconnecting from WhatsApp") client.Disconnect() } - - // 2. Remove from DB _, err = s.db.Exec("DELETE FROM users WHERE id = $1", id) if err != nil { - s.respondWithJSON(w, http.StatusInternalServerError, map[string]interface{}{ + s.Respond(w, r, http.StatusInternalServerError, map[string]interface{}{ "code": http.StatusInternalServerError, "error": "Database error", "success": false, @@ -4053,13 +5042,9 @@ func (s *server) DeleteUserComplete() http.HandlerFunc { }) return } - - // 3. Cleanup from memory clientManager.DeleteWhatsmeowClient(id) clientManager.DeleteHTTPClient(id) userinfocache.Delete(token) - - // 4. Remove media files userDirectory := filepath.Join(s.exPath, "files", id) if stat, err := os.Stat(userDirectory); err == nil && stat.IsDir() { log.Info().Str("dir", userDirectory).Msg("Deleting media and history files from disk") @@ -4068,11 +5053,8 @@ func (s *server) DeleteUserComplete() http.HandlerFunc { log.Error().Err(err).Str("dir", userDirectory).Msg("Erro ao remover diretório de mídia") } } - log.Info().Str("id", id).Str("name", uname).Str("jid", jid).Msg("User deleted successfully") - - // Success response - s.respondWithJSON(w, http.StatusOK, map[string]interface{}{ + s.Respond(w, r, http.StatusOK, map[string]interface{}{ "code": http.StatusOK, "data": map[string]interface{}{ "id": id, @@ -4089,70 +5071,96 @@ func (s *server) Respond(w http.ResponseWriter, r *http.Request, status int, dat w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - dataenvelope := map[string]interface{}{"code": status} + var responsePayload map[string]interface{} + if err, ok := data.(error); ok { - dataenvelope["error"] = err.Error() - dataenvelope["success"] = false - } else { - // Try to unmarshal into a map first - var mydata map[string]interface{} - if err := json.Unmarshal([]byte(data.(string)), &mydata); err == nil { - dataenvelope["data"] = mydata - } else { - // If unmarshaling into a map fails, try as a slice - var mySlice []interface{} - if err := json.Unmarshal([]byte(data.(string)), &mySlice); err == nil { - dataenvelope["data"] = mySlice - } else { - log.Error().Str("error", fmt.Sprintf("%v", err)).Msg("Error unmarshalling JSON") + responsePayload = map[string]interface{}{ + "code": status, + "error": err.Error(), + "success": false, + } + } else if dataMap, ok := data.(map[string]interface{}); ok { + // Check if the provided map looks like a full envelope already + // (e.g., as was passed by admin handlers to the old respondWithJSON) + if _, hasCode := dataMap["code"]; hasCode { + // If it has "code", assume it's a pre-formed envelope. Respect its structure. + responsePayload = dataMap + responsePayload["code"] = status // Ensure the function's status parameter overrides + if _, hasSuccess := dataMap["success"]; !hasSuccess { // ensure success field if missing + responsePayload["success"] = (status >= 200 && status < 300) + } + } else { // It's a data map, embed it under a "data" key + responsePayload = map[string]interface{}{ + "code": status, + "data": dataMap, + "success": true, } } - dataenvelope["success"] = true + } else if strData, ok := data.(string); ok { + // This handles cases where data is a pre-marshalled JSON string for the "data" field + // or just a plain string to be wrapped. + var jsonData interface{} // Use interface{} to handle both objects and arrays + if json.Unmarshal([]byte(strData), &jsonData) == nil { + responsePayload = map[string]interface{}{ + "code": status, + "data": jsonData, // jsonData is now unmarshalled Go data + "success": true, + } + } else { // If it's not valid JSON, treat as a plain string for the "data" field + responsePayload = map[string]interface{}{ + "code": status, + "data": strData, + "success": true, + } + } + } else if data != nil { // Other non-nil data (structs, slices etc.) + responsePayload = map[string]interface{}{ + "code": status, + "data": data, + "success": true, + } + } else { // data is nil (and not an error) + responsePayload = map[string]interface{}{ + "code": status, + "success": true, // Typically for GETs with no data or successful no-content actions + } } - if err := json.NewEncoder(w).Encode(dataenvelope); err != nil { - panic("respond: " + err.Error()) + if err := json.NewEncoder(w).Encode(responsePayload); err != nil { + // Log the error instead of panicking + log.Error().Err(err).Msg("Failed to encode JSON response in Respond") } } func validateMessageFields(phone string, stanzaid *string, participant *string) (types.JID, error) { - recipient, ok := parseJID(phone) if !ok { return types.NewJID("", types.DefaultUserServer), errors.New("Could not parse Phone") } - if stanzaid != nil { if participant == nil { return types.NewJID("", types.DefaultUserServer), errors.New("Missing Participant in ContextInfo") } } - if participant != nil { if stanzaid == nil { return types.NewJID("", types.DefaultUserServer), errors.New("Missing StanzaID in ContextInfo") } } - return recipient, nil } func (s *server) SetProxy() http.HandlerFunc { type proxyStruct struct { - ProxyURL string `json:"proxy_url"` // Format: "socks5://user:pass@host:port" or "http://host:port" - Enable bool `json:"enable"` // Whether to enable or disable proxy + ProxyURL string `json:"proxy_url"` + Enable bool `json:"enable"` } - return func(w http.ResponseWriter, r *http.Request) { txtid := r.Context().Value("userinfo").(Values).Get("Id") - - // Check if client exists and is connected - if clientManager.GetWhatsmeowClient(txtid) != nil && clientManager.GetWhatsmeowClient(txtid).IsConnected() { s.Respond(w, r, http.StatusBadRequest, errors.New("cannot set proxy while connected. Please disconnect first")) return } - decoder := json.NewDecoder(r.Body) var t proxyStruct err := decoder.Decode(&t) @@ -4160,15 +5168,12 @@ func (s *server) SetProxy() http.HandlerFunc { s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode payload")) return } - - // If enable is false, remove proxy configuration if !t.Enable { _, err = s.db.Exec("UPDATE users SET proxy_url = NULL WHERE id = $1", txtid) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New("failed to remove proxy configuration")) return } - response := map[string]interface{}{"Details": "Proxy disabled successfully"} responseJson, err := json.Marshal(response) if err != nil { @@ -4178,32 +5183,24 @@ func (s *server) SetProxy() http.HandlerFunc { } return } - - // Validate proxy URL if t.ProxyURL == "" { s.Respond(w, r, http.StatusBadRequest, errors.New("missing proxy_url in payload")) return } - proxyURL, err := url.Parse(t.ProxyURL) if err != nil { s.Respond(w, r, http.StatusBadRequest, errors.New("invalid proxy URL format")) return } - - // Only allow http and socks5 proxies if proxyURL.Scheme != "http" && proxyURL.Scheme != "socks5" { s.Respond(w, r, http.StatusBadRequest, errors.New("only HTTP and SOCKS5 proxies are supported")) return } - - // Store proxy configuration in database _, err = s.db.Exec("UPDATE users SET proxy_url = $1 WHERE id = $2", t.ProxyURL, txtid) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New("failed to save proxy configuration")) return } - response := map[string]interface{}{ "Details": "Proxy configured successfully", "ProxyURL": t.ProxyURL, @@ -4216,3 +5213,19 @@ func (s *server) SetProxy() http.HandlerFunc { } } } + +// Helper function (not a handler) +// This version takes an interface{} for the first argument to allow direct passing of context values, +// and then performs a type assertion. +func updateUserInfo(ctxvalue interface{}, key string, value string) Values { + v := ctxvalue.(Values) // This handles the type assertion + // Add nil check for robustness + if v.m == nil { + v.m = make(map[string]string) + } + v.m[key] = value + return v +} + +// Find takes a slice and looks for an element in it. If found it will +// respondWithJSON has been removed. Use s.Respond for standard responses. diff --git a/handlers_mode_test.go b/handlers_mode_test.go new file mode 100644 index 00000000..80b78012 --- /dev/null +++ b/handlers_mode_test.go @@ -0,0 +1,1433 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/jmoiron/sqlx" + "github.com/patrickmn/go-cache" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + _ "modernc.org/sqlite" +) + +var testServer *httptest.Server +var testDB *sqlx.DB +var testRouter *mux.Router +var S *server // Global server instance for tests + +const testAdminToken = "test_admin_token" +const testUserToken1 = "test_user_token_1" +const testUserID1 = "testuser1" +const testUserToken2 = "test_user_token_2" +const testUserID2 = "testuser2" + +// Mock GenerateRandomID for predictable IDs in tests if needed, +// for now, we will rely on checking other fields. + +func TestMain(m *testing.M) { + // Suppress log output during tests + zerolog.SetGlobalLevel(zerolog.Disabled) + + // Setup test database + var err error + testDB, err = setupTestDB() + if err != nil { + fmt.Printf("Failed to set up test database: %v\n", err) + os.Exit(1) + } + defer testDB.Close() + + // Setup server + ex, _ := os.Executable() + exPath := filepath.Dir(ex) + + adminTokenVar := testAdminToken // directly use const + S = &server{ + db: testDB, + router: mux.NewRouter(), + exPath: exPath, + whatsmeowOpts: defaultWhatsmeowOptions(), + } + adminToken = &adminTokenVar // Assign to global adminToken used by server + + // Initialize userinfocache (global variable used by authalice) + userinfocache = cache.New(cache.NoExpiration, 10*time.Minute) + // Pre-populate cache for test users + userinfocache.Set(testUserToken1, Values{map[string]string{"Id": testUserID1, "Name": "Test User 1"}}, cache.NoExpiration) + userinfocache.Set(testUserToken2, Values{map[string]string{"Id": testUserID2, "Name": "Test User 2"}}, cache.NoExpiration) + + // Initialize killchannel (global variable used by server) + killchannel = make(map[string]chan bool) + + // Initialize clientManager (global variable) + clientManager = NewClientManager() + + + S.routes() // Use the actual routes + testRouter = S.router + testServer = httptest.NewServer(testRouter) + defer testServer.Close() + + // Run tests + exitCode := m.Run() + os.Exit(exitCode) +} + +func setupTestDB() (*sqlx.DB, error) { + // Use an in-memory SQLite database for tests + // Ensure the path includes parameters for foreign keys and busy timeout for consistency + db, err := sqlx.Open("sqlite", "file:test_wuzapi.db?mode=memory&cache=shared&_pragma=foreign_keys(1)&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("failed to open in-memory sqlite database: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping test database: %w", err) + } + + // Create tables - adapting from db.go's createTables + // We need to ensure this is compatible with SQLite for tests + tablesSQL := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT, + token TEXT UNIQUE, + webhook TEXT, + jid TEXT, + events TEXT, + proxy_url TEXT, + qrcode TEXT, + connected INTEGER DEFAULT 0, + expiration INTEGER + );`, + `CREATE TABLE IF NOT EXISTS autoreply_modes ( + user_id TEXT NOT NULL, + mode_name TEXT NOT NULL, + phone_number TEXT NOT NULL, + message TEXT NOT NULL, + UNIQUE (user_id, mode_name, phone_number) + );`, + `CREATE TABLE IF NOT EXISTS active_mode ( + user_id TEXT PRIMARY KEY NOT NULL, + current_mode_name TEXT NULLABLE + );`, + `CREATE TABLE IF NOT EXISTS autoreplies ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + reply_body TEXT NOT NULL, + last_sent_at TIMESTAMP NULLABLE, + UNIQUE (user_id, phone_number) + );`, // Added from original migration + } + + for _, sql := range tablesSQL { + if _, err := db.Exec(sql); err != nil { + return nil, fmt.Errorf("failed to execute table creation SQL: %s, error: %w", sql, err) + } + } + return db, nil +} + +func clearAllTables(db *sqlx.DB) { + tables := []string{"autoreply_modes", "active_mode", "autoreplies", "users"} + for _, table := range tables { + // For SQLite, DELETE FROM should be fine. For PostgreSQL, TRUNCATE would be faster. + // Since this is SQLite for tests, DELETE FROM is okay. + _, err := db.Exec(fmt.Sprintf("DELETE FROM %s", table)) + if err != nil { + fmt.Printf("Failed to clear table %s: %v\n", table, err) + } + } + // Re-populate userinfocache for test users after clearing users table + userinfocache.Flush() // Clear existing cache + userinfocache.Set(testUserToken1, Values{map[string]string{"Id": testUserID1, "Name": "Test User 1"}}, cache.NoExpiration) + userinfocache.Set(testUserToken2, Values{map[string]string{"Id": testUserID2, "Name": "Test User 2"}}, cache.NoExpiration) + + // We also need to add the test users to the DB for authalice to find them if cache misses (though we pre-populate) + _, _ = db.Exec("INSERT INTO users (id, name, token) VALUES (?, ?, ?)", testUserID1, "Test User 1", testUserToken1) + _, _ = db.Exec("INSERT INTO users (id, name, token) VALUES (?, ?, ?)", testUserID2, "Test User 2", testUserToken2) + +} + + +func newAuthenticatedRequest(t *testing.T, method, path string, body io.Reader, userToken string, userID string) *http.Request { + req, err := http.NewRequest(method, testServer.URL+path, body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + // Simulate authalice middleware by setting userinfo in context + // In a real scenario, authalice would fetch this from DB or cache based on token + // For testing, we directly create the Values struct and put it in context. + // The token in header is mostly for completeness of the request object. + if userID != "" { + req.Header.Set("token", userToken) // authalice checks this token + userInfo := Values{m: map[string]string{ + "Id": userID, + "Name": "Test User", // Or fetch/map from userID if needed + "Token": userToken, + // Add other fields like Jid, Webhook etc. if your handlers depend on them + }} + ctx := context.WithValue(req.Context(), "userinfo", userInfo) + req = req.WithContext(ctx) + } + return req +} + + +// TestAddModeAutoreply covers POST /mode/autoreply +func TestAddModeAutoreply(t *testing.T) { + defer clearAllTables(testDB) + + tests := []struct { + name string + userID string + userToken string + payload interface{} + expectedStatus int + expectedBody string // Can be a regex or partial match + dbChecks func(t *testing.T, userID string) + }{ + { + name: "Successful new mode addition", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyRequest{ModeName: "Work", Phone: "111222333", Message: "Working remotely"}, + expectedStatus: http.StatusCreated, + expectedBody: `"detail":"Mode autoreply added/updated successfully"`, + dbChecks: func(t *testing.T, userID string) { + var count int + err := testDB.Get(&count, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = 'work' AND phone_number = '111222333'", userID) + require.NoError(t, err) + assert.Equal(t, 1, count, "Expected 1 entry in DB") + }, + }, + { + name: "Update existing mode message", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyRequest{ModeName: "Work", Phone: "111222333", Message: "Working from office today"}, // Same mode/phone + expectedStatus: http.StatusCreated, // Upsert behavior + expectedBody: `"detail":"Mode autoreply added/updated successfully"`, + dbChecks: func(t *testing.T, userID string) { + var msg string + err := testDB.Get(&msg, "SELECT message FROM autoreply_modes WHERE user_id = ? AND mode_name = 'work' AND phone_number = '111222333'", userID) + require.NoError(t, err) + assert.Equal(t, "Working from office today", msg) + }, + }, + { + name: "Invalid mode name - special chars", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyRequest{ModeName: "Work!", Phone: "123", Message: "Msg"}, + expectedStatus: http.StatusBadRequest, + expectedBody: `"error":"Invalid ModeName: must be alphanumeric"`, + }, + { + name: "Missing Phone", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyRequest{ModeName: "ValidMode", Phone: "", Message: "Msg"}, + expectedStatus: http.StatusBadRequest, + expectedBody: `"error":"Missing Phone in Payload"`, + }, + { + name: "User specificity - User 2 adds same mode name as User 1", + userID: testUserID2, + userToken: testUserToken2, + payload: ModeAutoreplyRequest{ModeName: "Work", Phone: "444555666", Message: "User 2 Work Message"}, + expectedStatus: http.StatusCreated, + expectedBody: `"detail":"Mode autoreply added/updated successfully"`, + dbChecks: func(t *testing.T, userID string) { + var countUser1, countUser2 int + err := testDB.Get(&countUser1, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = 'work'", testUserID1) + require.NoError(t, err) + assert.True(t, countUser1 >= 1, "User 1 should still have their 'work' mode entries") + + err = testDB.Get(&countUser2, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = 'work' AND phone_number = '444555666'", testUserID2) + require.NoError(t, err) + assert.Equal(t, 1, countUser2, "User 2 should have their 'work' mode entry") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // For tests that depend on prior state (like update), ensure that state exists + if tc.name == "Update existing mode message" { + setupPayload := ModeAutoreplyRequest{ModeName: "Work", Phone: "111222333", Message: "Initial message"} + jsonBody, _ := json.Marshal(setupPayload) + req := newAuthenticatedRequest(t, "POST", "/autoreply/mode", bytes.NewBuffer(jsonBody), testUserToken1, testUserID1) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) // Use testRouter directly + require.Equal(t, http.StatusCreated, rr.Code) + } + + + jsonBody, err := json.Marshal(tc.payload) + require.NoError(t, err) + + req := newAuthenticatedRequest(t, "POST", "/autoreply/mode", bytes.NewBuffer(jsonBody), tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + bodyString := rr.Body.String() + assert.Contains(t, bodyString, tc.expectedBody, "Response body mismatch") + + if tc.dbChecks != nil { + tc.dbChecks(t, tc.userID) + } + // Clean up only this user's data if needed for next sub-test, or rely on defer clearAllTables + // clearUserSpecificData(testDB, tc.userID, "autoreply_modes") + }) + } +} + +// TestDeleteModeAutoreply covers DELETE /mode/autoreply +func TestDeleteModeAutoreply(t *testing.T) { + defer clearAllTables(testDB) + + // Setup initial data for user1 + _, err := testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "holiday", "123", "On holiday") + require.NoError(t, err) + _, err = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "holiday", "456", "Still on holiday") + require.NoError(t, err) + _, err = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "work", "789", "Working") + require.NoError(t, err) + + + tests := []struct { + name string + userID string + userToken string + payload interface{} + expectedStatus int + expectedDetailRegex string // Regex for detail message + dbChecks func(t *testing.T, userID string) + }{ + { + name: "Delete specific phone from mode", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyDeleteRequest{ModeName: "holiday", Phone: "123"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `1 autoreply entry\(s\) deleted for mode 'holiday'`, + dbChecks: func(t *testing.T, userID string) { + var count int + err := testDB.Get(&count, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = 'holiday' AND phone_number = '123'", userID) + require.NoError(t, err) + assert.Equal(t, 0, count) + err = testDB.Get(&count, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = 'holiday' AND phone_number = '456'", userID) + require.NoError(t, err) + assert.Equal(t, 1, count) // Other phone for same mode should remain + }, + }, + { + name: "Delete all phones for a mode", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyDeleteRequest{ModeName: "holiday"}, // No phone specified + expectedStatus: http.StatusOK, + // After previous test, only '456' is left in 'holiday' mode + expectedDetailRegex: `1 autoreply entry\(s\) deleted for mode 'holiday'`, + dbChecks: func(t *testing.T, userID string) { + var count int + err := testDB.Get(&count, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = 'holiday'", userID) + require.NoError(t, err) + assert.Equal(t, 0, count) + }, + }, + { + name: "Delete non-existent phone from existing mode", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyDeleteRequest{ModeName: "work", Phone: "000"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `No autoreply entries found or deleted for mode 'work'`, + }, + { + name: "Delete non-existent mode", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyDeleteRequest{ModeName: "nonexistent"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `No autoreply entries found or deleted for mode 'nonexistent'`, + }, + { + name: "Invalid mode name", + userID: testUserID1, + userToken: testUserToken1, + payload: ModeAutoreplyDeleteRequest{ModeName: "work!"}, + expectedStatus: http.StatusBadRequest, + expectedDetailRegex: `"error":"Invalid ModeName: must be alphanumeric"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + jsonBody, err := json.Marshal(tc.payload) + require.NoError(t, err) + + req := newAuthenticatedRequest(t, "DELETE", "/autoreply/mode", bytes.NewBuffer(jsonBody), tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + bodyString := rr.Body.String() + + match, _ := regexp.MatchString(tc.expectedDetailRegex, bodyString) + assert.True(t, match, "Response body detail mismatch. Expected regex: %s, Got: %s", tc.expectedDetailRegex, bodyString) + + + if tc.dbChecks != nil { + tc.dbChecks(t, tc.userID) + } + }) + } +} + + +// TestGetModeAutoreplies covers GET /mode/autoreply +func TestGetModeAutoreplies(t *testing.T) { + defer clearAllTables(testDB) + + // Setup data for user1 + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "travel", "111", "Away travelling") + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "travel", "222", "Still travelling") + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "meeting", "333", "In a meeting") + // Setup data for user2 + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID2, "travel", "999", "User 2 travelling") + + + tests := []struct { + name string + userID string + userToken string + modeNameQuery string // e.g., "?modeName=travel" or "" + expectedStatus int + expectedCount int // Number of entries expected in the "data" array + expectedContentPart string // A part of the content to check if count > 0 + }{ + { + name: "Get all modes for user1", + userID: testUserID1, + userToken: testUserToken1, + modeNameQuery: "", + expectedStatus: http.StatusOK, + expectedCount: 3, + expectedContentPart: `"ModeName":"travel"`, + }, + { + name: "Get specific mode 'travel' for user1", + userID: testUserID1, + userToken: testUserToken1, + modeNameQuery: "?modeName=travel", + expectedStatus: http.StatusOK, + expectedCount: 2, + expectedContentPart: `"Phone":"111"`, + }, + { + name: "Get specific mode 'meeting' for user1", + userID: testUserID1, + userToken: testUserToken1, + modeNameQuery: "?modeName=meeting", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedContentPart: `"Message":"In a meeting"`, + }, + { + name: "Get non-existent mode for user1", + userID: testUserID1, + userToken: testUserToken1, + modeNameQuery: "?modeName=nonexistent", + expectedStatus: http.StatusOK, + expectedCount: 0, + }, + { + name: "Get modes for user2 (should only get user2's data)", + userID: testUserID2, + userToken: testUserToken2, + modeNameQuery: "", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedContentPart: `"Phone":"999"`, + }, + { + name: "Get modes for user with no modes configured", + userID: "userwithnomodes", // Assume this user has no data + userToken: "tokenforuserwithnomodes", + modeNameQuery: "", + expectedStatus: http.StatusOK, + expectedCount: 0, + }, + { + name: "Invalid modeName query param", + userID: testUserID1, + userToken: testUserToken1, + modeNameQuery: "?modeName=invalid!", + expectedStatus: http.StatusBadRequest, + expectedContentPart: `"error":"Invalid modeName parameter: must be alphanumeric"`, + }, + } + // Add the temporary user for the "no modes" test + userinfocache.Set("tokenforuserwithnomodes", Values{map[string]string{"Id": "userwithnomodes", "Name": "No Modes User"}}, cache.NoExpiration) + _, _ = testDB.Exec("INSERT INTO users (id, name, token) VALUES (?, ?, ?)", "userwithnomodes", "No Modes User", "tokenforuserwithnomodes") + + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := newAuthenticatedRequest(t, "GET", "/autoreply/mode"+tc.modeNameQuery, nil, tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + bodyString := rr.Body.String() + if tc.expectedStatus == http.StatusOK { + var responseData struct { + Data []ModeAutoreplyEntry `json:"data"` + Success bool `json:"success"` + Code int `json:"code"` + } + err := json.Unmarshal([]byte(bodyString), &responseData) + require.NoError(t, err, "Failed to unmarshal response: %s", bodyString) + assert.True(t, responseData.Success) + assert.Equal(t, tc.expectedStatus, responseData.Code) + if assert.NotNil(t, responseData.Data) { + assert.Len(t, responseData.Data, tc.expectedCount) + if tc.expectedCount > 0 && tc.expectedContentPart != "" { + assert.Contains(t, bodyString, tc.expectedContentPart, "Response body content mismatch") + } + } + } else { + // For error responses, check the error message part + if tc.expectedContentPart != "" { + assert.Contains(t, bodyString, tc.expectedContentPart, "Error response body mismatch") + } + } + }) + } +} + +// TestEnableMode covers POST /mode/enablemode +func TestEnableMode(t *testing.T) { + defer clearAllTables(testDB) + + // Setup: User1 has 'vacation' mode with 2 entries. User2 has 'vacation' mode with 1 entry. + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "vacation", "111", "User1 Vacation 1") + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "vacation", "222", "User1 Vacation 2") + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "empty_mode", "777", "this should be cleared") // For testing clearing + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID2, "vacation", "999", "User2 Vacation") + + tests := []struct { + name string + userID string + userToken string + payload EnableModeRequest + expectedStatus int + expectedDetailRegex string + dbChecks func(t *testing.T, userID string) + }{ + { + name: "Enable valid mode for User1", + userID: testUserID1, + userToken: testUserToken1, + payload: EnableModeRequest{ModeName: "vacation"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `Mode 'vacation' enabled successfully. 2 autoreplies activated.`, + dbChecks: func(t *testing.T, userID string) { + // Check active_mode + var activeMode sql.NullString + err := testDB.Get(&activeMode, "SELECT current_mode_name FROM active_mode WHERE user_id = ?", userID) + require.NoError(t, err) + require.True(t, activeMode.Valid, "current_mode_name should be valid") + assert.Equal(t, "vacation", activeMode.String) + + // Check autoreplies table + var replies []AutoReplyEntry + err = testDB.Select(&replies, "SELECT phone_number, reply_body FROM autoreplies WHERE user_id = ?", userID) + require.NoError(t, err) + assert.Len(t, replies, 2) + // Check if correct entries were added (order might not be guaranteed) + expectedReplies := map[string]string{"111": "User1 Vacation 1", "222": "User1 Vacation 2"} + foundReplies := make(map[string]string) + for _, r := range replies { + foundReplies[r.Phone] = r.Body + } + assert.Equal(t, expectedReplies, foundReplies) + }, + }, + { + name: "Enable mode with no entries", + userID: testUserID1, + userToken: testUserToken1, + payload: EnableModeRequest{ModeName: "work"}, // Assume 'work' mode has no entries for user1 + expectedStatus: http.StatusOK, + expectedDetailRegex: `Mode 'work' enabled successfully. 0 autoreplies activated.`, + dbChecks: func(t *testing.T, userID string) { + var activeMode sql.NullString + err := testDB.Get(&activeMode, "SELECT current_mode_name FROM active_mode WHERE user_id = ?", userID) + require.NoError(t, err) + require.True(t, activeMode.Valid) + assert.Equal(t, "work", activeMode.String) + var count int + err = testDB.Get(&count, "SELECT COUNT(*) FROM autoreplies WHERE user_id = ?", userID) + require.NoError(t, err) + assert.Equal(t, 0, count) + }, + }, + { + name: "Enable non-existent mode", // The handler logic currently creates the mode in active_mode and activates 0 replies. + userID: testUserID1, + userToken: testUserToken1, + payload: EnableModeRequest{ModeName: "nonexistent"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `Mode 'nonexistent' enabled successfully. 0 autoreplies activated.`, + }, + { + name: "Invalid mode name", + userID: testUserID1, + userToken: testUserToken1, + payload: EnableModeRequest{ModeName: "bad!"}, + expectedStatus: http.StatusBadRequest, + expectedDetailRegex: `"error":"Invalid ModeName: must be alphanumeric"`, + }, + { + name: "User specificity - Enable User2's mode", + userID: testUserID2, + userToken: testUserToken2, + payload: EnableModeRequest{ModeName: "vacation"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `Mode 'vacation' enabled successfully. 1 autoreplies activated.`, + dbChecks: func(t *testing.T, userID string) { + // Check active_mode for User2 + var activeModeUser2 sql.NullString + err := testDB.Get(&activeModeUser2, "SELECT current_mode_name FROM active_mode WHERE user_id = ?", testUserID2) + require.NoError(t, err) + require.True(t, activeModeUser2.Valid) + assert.Equal(t, "vacation", activeModeUser2.String) + // Check autoreplies for User2 + var repliesUser2 []AutoReplyEntry + err = testDB.Select(&repliesUser2, "SELECT phone_number, reply_body FROM autoreplies WHERE user_id = ?", testUserID2) + require.NoError(t, err) + assert.Len(t, repliesUser2, 1) + assert.Equal(t, "999", repliesUser2[0].Phone) + + // Ensure User1's active_mode (if set by a previous subtest) is not affected or is as expected + // This requires careful sequencing or resetting User1's state if subtests are not isolated. + // For now, we assume subtests might affect each other if not reset. + // Let's check User1's autoreplies count, it should be 0 if "work" or "nonexistent" was enabled for user1 previously. + var user1AutoreplyCount int + _ = testDB.Get(&user1AutoreplyCount, "SELECT COUNT(*) from autoreplies WHERE user_id=?", testUserID1) + assert.Equal(t, 0, user1AutoreplyCount, "User1's autoreplies should be 0 if previous test ran") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // If a test depends on a clean slate for a user (e.g. to check initial enabling) + // you might need to clear that user's active_mode and autoreplies here. + if tc.name == "Enable valid mode for User1" { // Reset before this specific one + _, _ = testDB.Exec("DELETE FROM active_mode WHERE user_id = ?", testUserID1) + _, _ = testDB.Exec("DELETE FROM autoreplies WHERE user_id = ?", testUserID1) + } + + + jsonBody, err := json.Marshal(tc.payload) + require.NoError(t, err) + + req := newAuthenticatedRequest(t, "POST", "/autoreply/enablemode", bytes.NewBuffer(jsonBody), tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + bodyString := rr.Body.String() + match, _ := regexp.MatchString(tc.expectedDetailRegex, bodyString) + assert.True(t, match, "Response body detail mismatch. Expected regex: %s, Got: %s", tc.expectedDetailRegex, bodyString) + + if tc.dbChecks != nil { + tc.dbChecks(t, tc.userID) + } + }) + } +} + +// TestDisableMode covers POST /mode/disablemode +func TestDisableMode(t *testing.T) { + defer clearAllTables(testDB) + + // Setup: User1 has 'activemode' active and some autoreplies. User2 has no active mode. + _, _ = testDB.Exec("INSERT INTO active_mode (user_id, current_mode_name) VALUES (?, ?)", testUserID1, "activemode") + _, _ = testDB.Exec("INSERT INTO autoreplies (id, user_id, phone_number, reply_body) VALUES (?, ?, ?, ?)", "reply1", testUserID1, "123", "Active reply") + + + tests := []struct { + name string + userID string + userToken string + payload DisableModeRequest + expectedStatus int + expectedDetailRegex string + dbChecks func(t *testing.T, userID string) + }{ + { + name: "Disable currently active mode", + userID: testUserID1, + userToken: testUserToken1, + payload: DisableModeRequest{ModeName: "activemode"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `Mode 'activemode' disabled successfully.`, + dbChecks: func(t *testing.T, userID string) { + var activeMode sql.NullString + err := testDB.Get(&activeMode, "SELECT current_mode_name FROM active_mode WHERE user_id = ?", userID) + if err != nil && err != sql.ErrNoRows { require.NoError(t, err) } // Allow ErrNoRows if row is deleted + assert.False(t, activeMode.Valid, "current_mode_name should be NULL or row gone") + + var count int + err = testDB.Get(&count, "SELECT COUNT(*) FROM autoreplies WHERE user_id = ?", userID) + require.NoError(t, err) + assert.Equal(t, 0, count, "Autoreplies should be cleared") + }, + }, + { + name: "Disable a mode that is not active", + userID: testUserID1, // User1's mode is now NULL from previous test + userToken: testUserToken1, + payload: DisableModeRequest{ModeName: "someothermode"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `Mode 'someothermode' was not active or does not exist. No changes made.`, + dbChecks: func(t *testing.T, userID string) { + var activeMode sql.NullString + // Ensure active_mode is still NULL (or row doesn't exist) + err := testDB.Get(&activeMode, "SELECT current_mode_name FROM active_mode WHERE user_id = ?", userID) + if err != sql.ErrNoRows { // If row exists, it must be NULL + require.NoError(t, err) + assert.False(t, activeMode.Valid) + } + }, + }, + { + name: "Disable non-existent mode for user with no active mode", + userID: testUserID2, // User2 has no active mode initially + userToken: testUserToken2, + payload: DisableModeRequest{ModeName: "nonexistent"}, + expectedStatus: http.StatusOK, + expectedDetailRegex: `Mode 'nonexistent' was not active or does not exist. No changes made.`, + }, + { + name: "Invalid mode name", + userID: testUserID1, + userToken: testUserToken1, + payload: DisableModeRequest{ModeName: "bad!"}, + expectedStatus: http.StatusBadRequest, + expectedDetailRegex: `"error":"Invalid ModeName: must be alphanumeric"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + jsonBody, err := json.Marshal(tc.payload) + require.NoError(t, err) + + req := newAuthenticatedRequest(t, "POST", "/autoreply/disablemode", bytes.NewBuffer(jsonBody), tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + bodyString := rr.Body.String() + match, _ := regexp.MatchString(tc.expectedDetailRegex, bodyString) + assert.True(t, match, "Response body detail mismatch. Expected regex: %s, Got: %s", tc.expectedDetailRegex, bodyString) + + if tc.dbChecks != nil { + tc.dbChecks(t, tc.userID) + } + }) + } +} + +// TestGetCurrentMode covers GET /mode/currentmode +func TestGetCurrentMode(t *testing.T) { + defer clearAllTables(testDB) + + // Setup: User1 has 'holiday' active. User2 has no entry in active_mode. User3 has entry but NULL. + _, _ = testDB.Exec("INSERT INTO active_mode (user_id, current_mode_name) VALUES (?, ?)", testUserID1, "holiday") + + // User testUserID2 has no entry in active_mode + + // User "testuser3" for NULL mode + testUserID3 := "testuser3" + testUserToken3 := "test_user_token_3" + userinfocache.Set(testUserToken3, Values{map[string]string{"Id": testUserID3, "Name": "Test User 3"}}, cache.NoExpiration) + _, _ = testDB.Exec("INSERT INTO users (id, name, token) VALUES (?, ?, ?)", testUserID3, "Test User 3", testUserToken3) + _, _ = testDB.Exec("INSERT INTO active_mode (user_id, current_mode_name) VALUES (?, NULL)", testUserID3) + + + tests := []struct { + name string + userID string + userToken string + expectedStatus int + expectedMode interface{} // string or nil + }{ + { + name: "Get active mode for User1", + userID: testUserID1, + userToken: testUserToken1, + expectedStatus: http.StatusOK, + expectedMode: "holiday", + }, + { + name: "Get active mode for User2 (no entry)", + userID: testUserID2, + userToken: testUserToken2, + expectedStatus: http.StatusOK, + expectedMode: nil, // Expecting null if no mode is active + }, + { + name: "Get active mode for User3 (entry is NULL)", + userID: testUserID3, + userToken: testUserToken3, + expectedStatus: http.StatusOK, + expectedMode: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := newAuthenticatedRequest(t, "GET", "/autoreply/currentmode", nil, tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + var response struct { + Data struct { + CurrentModeName interface{} `json:"current_mode_name"` + } `json:"data"` + Success bool `json:"success"` + } + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + assert.True(t, response.Success) + assert.Equal(t, tc.expectedMode, response.Data.CurrentModeName) + }) + } +} + +// TestClearModes covers POST /mode/clear +func TestClearModes(t *testing.T) { + defer clearAllTables(testDB) + + // Setup: User1 has 'work' active and some autoreplies. User2 has no active mode and no autoreplies. + _, _ = testDB.Exec("INSERT INTO active_mode (user_id, current_mode_name) VALUES (?, ?)", testUserID1, "work") + _, _ = testDB.Exec("INSERT INTO autoreplies (id, user_id, phone_number, reply_body) VALUES (?, ?, ?, ?)", "reply_work", testUserID1, "789", "Working") + + tests := []struct { + name string + userID string + userToken string + expectedStatus int + expectedDetail string + dbChecks func(t *testing.T, userID string) + }{ + { + name: "Clear when a mode is active for User1", + userID: testUserID1, + userToken: testUserToken1, + expectedStatus: http.StatusOK, + expectedDetail: "All modes cleared and current mode deactivated successfully.", + dbChecks: func(t *testing.T, userID string) { + var activeMode sql.NullString + err := testDB.Get(&activeMode, "SELECT current_mode_name FROM active_mode WHERE user_id = ?", userID) + // After clear, the row in active_mode should exist and be NULL (due to handler logic) + require.NoError(t, err, "Should still have a row in active_mode or it was correctly handled") + assert.False(t, activeMode.Valid, "current_mode_name should be NULL") + + var count int + err = testDB.Get(&count, "SELECT COUNT(*) FROM autoreplies WHERE user_id = ?", userID) + require.NoError(t, err) + assert.Equal(t, 0, count, "Autoreplies should be cleared") + }, + }, + { + name: "Clear when no mode is active for User2", + userID: testUserID2, + userToken: testUserToken2, + expectedStatus: http.StatusOK, + expectedDetail: "All modes cleared and current mode deactivated successfully.", + dbChecks: func(t *testing.T, userID string) { + var activeMode sql.NullString + err := testDB.Get(&activeMode, "SELECT current_mode_name FROM active_mode WHERE user_id = ?", userID) + // After clear, User2 should have an entry in active_mode set to NULL + require.NoError(t, err, "User should have an entry in active_mode after clear") + assert.False(t, activeMode.Valid, "current_mode_name should be NULL") + + var count int + err = testDB.Get(&count, "SELECT COUNT(*) FROM autoreplies WHERE user_id = ?", userID) + require.NoError(t, err) + assert.Equal(t, 0, count, "Autoreplies should remain 0") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := newAuthenticatedRequest(t, "POST", "/autoreply/clearmode", nil, tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + var response struct { + Data struct { + Detail string `json:"detail"` + } `json:"data"` + Success bool `json:"success"` + } + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + assert.True(t, response.Success) + assert.Equal(t, tc.expectedDetail, response.Data.Detail) + + if tc.dbChecks != nil { + tc.dbChecks(t, tc.userID) + } + }) + } +} + +func TestIsValidModeName(t *testing.T) { + assert.True(t, isValidModeName("work")) + assert.True(t, isValidModeName("Work123")) + assert.True(t, isValidModeName("OFFICE")) + assert.False(t, isValidModeName("work!")) + assert.False(t, isValidModeName("work mode")) + assert.False(t, isValidModeName("")) + assert.False(t, isValidModeName(" test")) +} + +func TestNormalizePhoneNumber(t *testing.T) { + tests := []struct { + name string + input string + expected string + expectError bool + expectedError string + }{ + {"empty input", "", "", true, "phone number is empty after cleaning"}, + {"short input", "12345", "", true, "phone number '12345' has invalid length after normalization"}, + {"long input", "1234567890123456", "", true, "phone number '1234567890123456' has invalid length after normalization"}, + {"invalid chars", "abc", "", true, "phone number is empty after cleaning"}, + {"valid 10 digit (India)", "9876543210", "919876543210", false, ""}, + {"valid 10 digit with spaces (India)", " 98765 43210 ", "919876543210", false, ""}, + {"valid US with + and hyphens", "+1-555-123-4567", "15551234567", false, ""}, + {"valid US with () and spaces", " (555) 876-5432 ", "5558765432", false, ""}, // Assuming non-prefixed 10-digit becomes Indian + {"valid UK with + and hyphens", "+44-20-1234-5678", "442012345678", false, ""}, + {"just +", "+", "", true, "phone number is empty after cleaning"}, + {"valid with extension (not handled, cleaned)", "+1234567890x123", "1234567890123", false, ""}, // x123 is cleaned out + {"leading zeros", "0011234567890", "11234567890", false, ""}, // Assuming 00 is not a country code here, simple cleaning + {"already normalized with 91", "919988776655", "919988776655", false, ""}, + {"already normalized US", "18005551212", "18005551212", false, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + normalized, err := normalizePhoneNumber(tc.input) + if tc.expectError { + assert.Error(t, err) + if tc.expectedError != "" { + assert.Contains(t, err.Error(), tc.expectedError) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, normalized) + } + }) + } + // Correcting a specific case based on current normalizePhoneNumber logic + // " (555) 876-5432 " will become "5558765432", and then prefixed with "91" + // because it's 10 digits and does not start with '+'. + t.Run("US number without plus becomes indian", func(t *testing.T){ + normalized, err := normalizePhoneNumber(" (555) 876-5432 ") + assert.NoError(t, err) + assert.Equal(t, "915558765432", normalized) + }) +} + + +// Helper function to set google_contacts_auth_token for a user +func setGoogleTokenForUser(t *testing.T, userID, token string) { + _, err := testDB.Exec("UPDATE users SET google_contacts_auth_token = ? WHERE id = ?", token, userID) + require.NoError(t, err, "Failed to set Google token for user %s", userID) +} + + +func TestSetGoogleContactsAuthToken(t *testing.T) { + defer clearAllTables(testDB) + + tests := []struct { + name string + userID string + userToken string + payload AuthTokenRequest + expectedStatus int + expectedBody string + dbCheck func(t *testing.T, userID string, expectedToken string) + }{ + { + name: "Successful token storage", + userID: testUserID1, + userToken: testUserToken1, + payload: AuthTokenRequest{AuthToken: "sample-google-token-123"}, + expectedStatus: http.StatusOK, + expectedBody: `"detail":"Auth token stored successfully"`, + dbCheck: func(t *testing.T, userID string, expectedToken string) { + var token sql.NullString + err := testDB.Get(&token, "SELECT google_contacts_auth_token FROM users WHERE id = ?", userID) + require.NoError(t, err) + require.True(t, token.Valid) + assert.Equal(t, expectedToken, token.String) + }, + }, + { + name: "Empty token in payload", + userID: testUserID1, + userToken: testUserToken1, + payload: AuthTokenRequest{AuthToken: " "}, // Whitespace only + expectedStatus: http.StatusBadRequest, + expectedBody: `"error":"Missing AuthToken in Payload"`, + }, + { + name: "Update existing token", + userID: testUserID1, + userToken: testUserToken1, + payload: AuthTokenRequest{AuthToken: "new-updated-token-456"}, + expectedStatus: http.StatusOK, + expectedBody: `"detail":"Auth token stored successfully"`, + dbCheck: func(t *testing.T, userID string, expectedToken string) { + var token sql.NullString + err := testDB.Get(&token, "SELECT google_contacts_auth_token FROM users WHERE id = ?", userID) + require.NoError(t, err) + require.True(t, token.Valid) + assert.Equal(t, expectedToken, token.String) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // For "Update existing token", first set an initial token + if tc.name == "Update existing token" { + setGoogleTokenForUser(t, testUserID1, "initial-token-000") + } + + jsonBody, err := json.Marshal(tc.payload) + require.NoError(t, err) + + req := newAuthenticatedRequest(t, "POST", "/autoreply/contactgroupauth", bytes.NewBuffer(jsonBody), tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + assert.Contains(t, rr.Body.String(), tc.expectedBody) + + if tc.dbCheck != nil { + tc.dbCheck(t, tc.userID, tc.payload.AuthToken) + } + }) + } +} + + +func TestAddContactGroupToMode(t *testing.T) { + defer clearAllTables(testDB) + + // Pre-set auth token for testUserID1 + setGoogleTokenForUser(t, testUserID1, "valid-google-token") + + // Store original fetch function to restore after tests + originalFetchContactsFunc := fetchContactsFromGoogleGroupFunc + defer func() { fetchContactsFromGoogleGroupFunc = originalFetchContactsFunc }() + + tests := []struct { + name string + userID string + userToken string + payload ContactGroupRequest + expectedStatus int + expectedBodyRegex string // Regex for detailed message checks + dbChecks func(t *testing.T, userID, modeName string) + }{ + { + name: "Successful add - default group", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "Holiday", GroupName: "Default Google Group", Message: "Away on holiday!"}, + expectedStatus: http.StatusOK, + expectedBodyRegex: `3 contacts processed and added/updated for mode 'holiday'. 3 contacts skipped.`, // Based on placeholder's default + dbChecks: func(t *testing.T, userID, modeName string) { + var count int + // Expected normalized numbers from placeholder: "11234567890", "919876543210", "442012345678" + err := testDB.Get(&count, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = ?", userID, strings.ToLower(modeName)) + require.NoError(t, err) + assert.Equal(t, 3, count, "Should have 3 valid contacts added") + + var msg string + err = testDB.Get(&msg, "SELECT message FROM autoreply_modes WHERE user_id = ? AND mode_name = ? AND phone_number = ?", userID, strings.ToLower(modeName), "919876543210") + require.NoError(t, err) + assert.Equal(t, "Away on holiday!", msg) + }, + }, + { + name: "Add with 'work contacts' group", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "WorkMode", GroupName: "Work Contacts", Message: "Busy with work!"}, + expectedStatus: http.StatusOK, + expectedBodyRegex: `2 contacts processed and added/updated for mode 'workmode'. 0 contacts skipped.`, + dbChecks: func(t *testing.T, userID, modeName string) { + var entries []ModeAutoreplyEntry + err := testDB.Select(&entries, "SELECT phone_number, message FROM autoreply_modes WHERE user_id = ? AND mode_name = ?", userID, strings.ToLower(modeName)) + require.NoError(t, err) + assert.Len(t, entries, 2) + expectedPhones := map[string]bool{"15551234567": false, "915558765432": false} // 5558765432 becomes 915558765432 + for _, e := range entries { + if _, ok := expectedPhones[e.Phone]; ok { + expectedPhones[e.Phone] = true + assert.Equal(t, "Busy with work!", e.Message) + } + } + for phone, found := range expectedPhones { + assert.True(t, found, "Expected phone %s not found", phone) + } + }, + }, + { + name: "Token not configured for user", + userID: testUserID2, // UserID2 has no token set yet + userToken: testUserToken2, + payload: ContactGroupRequest{ModeName: "AnyMode", GroupName: "AnyGroup", Message: "Msg"}, + expectedStatus: http.StatusForbidden, + expectedBodyRegex: `"error":"Google Contacts API token not configured`, + }, + { + name: "Invalid ModeName", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "Invalid!", GroupName: "AnyGroup", Message: "Msg"}, + expectedStatus: http.StatusBadRequest, + expectedBodyRegex: `"error":"Invalid ModeName: must be alphanumeric"`, + }, + { + name: "Missing GroupName", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "ValidMode", GroupName: " ", Message: "Msg"}, + expectedStatus: http.StatusBadRequest, + expectedBodyRegex: `"error":"Missing GroupName in Payload"`, + }, + { + name: "fetchContactsFromGoogleGroup returns error", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "ErrorTest", GroupName: "errorgroup", Message: "This will fail"}, + expectedStatus: http.StatusInternalServerError, + expectedBodyRegex: `"error":"Failed to process contact group"`, + }, + { + name: "fetchContactsFromGoogleGroup returns empty list", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "EmptyTest", GroupName: "emptygroup", Message: "No one here"}, + expectedStatus: http.StatusOK, + expectedBodyRegex: `No contacts found or processed for group 'emptygroup'.`, // Adjusted to expect detail, not error + }, + { + name: "Google API returns UNAUTHENTICATED error", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "AuthTest", GroupName: "auth_error_group", Message: "Test"}, + expectedStatus: http.StatusForbidden, + expectedBodyRegex: `"error":"Failed to authenticate with Google Contacts API. Please check your token or re-authenticate via /autoreply/contactgroupauth."`, + }, + { + name: "Google API returns PERMISSION_DENIED error", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "PermTest", GroupName: "perm_denied_group", Message: "Test"}, + expectedStatus: http.StatusForbidden, + expectedBodyRegex: `"error":"Failed to authenticate with Google Contacts API. Please check your token or re-authenticate via /autoreply/contactgroupauth."`, + }, + { + name: "Google API returns group not found error", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "NotFoundTest", GroupName: "non_existent_google_group", Message: "Test"}, + expectedStatus: http.StatusNotFound, + expectedBodyRegex: `"error":"Specified contact group 'non_existent_google_group' not found."`, + }, + { + name: "Google API returns generic error", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupRequest{ModeName: "GenericErrorTest", GroupName: "generic_google_api_error_group", Message: "Test"}, + expectedStatus: http.StatusInternalServerError, + expectedBodyRegex: `"error":"Error processing contacts from Google group."`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup mock for fetchContactsFromGoogleGroupFunc + fetchContactsFromGoogleGroupFunc = func(authToken string, groupName string, forUserLog string) ([]map[string]string, error) { + if groupName == "Default Google Group" { + return []map[string]string{ + {"name": "Test Contact 1", "phoneNumber": "+11234567890"}, + {"name": "Test Contact 2", "phoneNumber": "9876543210"}, + {"name": "Test Contact 3", "phoneNumber": "invalid-number"}, + {"name": "Test Contact 4", "phoneNumber": "+44-20-1234-5678"}, + {"name": "Test Contact 5", "phoneNumber": "12345"}, + {"name": "Test Contact 6", "phoneNumber": ""}, + }, nil + } + if groupName == "Work Contacts" { + return []map[string]string{ + {"name": "Alice Smith", "phoneNumber": "+15551234567"}, + {"name": "Bob Johnson", "phoneNumber": " (555) 876-5432"}, + }, nil + } + if groupName == "errorgroup" { + return nil, errors.New("simulated error fetching contacts from Google Group") + } + if groupName == "emptygroup" { + return []map[string]string{}, nil + } + if groupName == "auth_error_group" { + return nil, errors.New("Google API error: Some message (Status: UNAUTHENTICATED)") + } + if groupName == "perm_denied_group" { + return nil, errors.New("Google API error: Some message (Status: PERMISSION_DENIED)") + } + if groupName == "non_existent_google_group" { + return nil, fmt.Errorf("contact group '%s' not found for user %s", groupName, forUserLog) + } + if groupName == "generic_google_api_error_group" { + return nil, errors.New("some other Google API error") + } + return nil, fmt.Errorf("unexpected groupName in mock: %s", groupName) + } + + jsonBody, err := json.Marshal(tc.payload) + require.NoError(t, err) + + req := newAuthenticatedRequest(t, "POST", "/autoreply/contactgroup", bytes.NewBuffer(jsonBody), tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + bodyString := rr.Body.String() + match, _ := regexp.MatchString(tc.expectedBodyRegex, bodyString) + assert.True(t, match, "Response body regex mismatch.\nExpected regex: %s\nGot: %s", tc.expectedBodyRegex, bodyString) + + if tc.dbChecks != nil { + tc.dbChecks(t, tc.userID, tc.payload.ModeName) + } + }) + } +} + + +func TestDeleteContactGroupFromMode(t *testing.T) { + defer clearAllTables(testDB) + + // Pre-set auth token for testUserID1 + setGoogleTokenForUser(t, testUserID1, "valid-google-token") + // Setup initial data for User1, Mode "cleaningmode" + // Contacts from default mock: "11234567890", "919876543210", "442012345678" + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "cleaningmode", "11234567890", "To be deleted") + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "cleaningmode", "919876543210", "Also deleted") // This will be 91919876543210 after normalization in test + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "cleaningmode", "442012345678", "This one too") + _, _ = testDB.Exec("INSERT INTO autoreply_modes (user_id, mode_name, phone_number, message) VALUES (?, ?, ?, ?)", testUserID1, "cleaningmode", "0000000000", "Keep this one") // Not in placeholder group + + tests := []struct{ + name string + userID string + userToken string + payload ContactGroupDeleteRequest + expectedStatus int + expectedBodyRegex string + dbChecks func(t *testing.T, userID, modeName string) + }{ + { + name: "Successful delete - default group", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "CleaningMode", GroupName: "Default Google Group"}, // Uses default placeholder + expectedStatus: http.StatusOK, + // Mock has 6 contacts, 3 invalid/empty, 3 valid. So 3 processed, 3 deleted. + expectedBodyRegex: `3 contacts from group 'Default Google Group' processed for deletion from mode 'cleaningmode'. 3 entries actually deleted. 3 contacts skipped.`, + dbChecks: func(t *testing.T, userID, modeName string) { + var count int + err := testDB.Get(&count, "SELECT COUNT(*) FROM autoreply_modes WHERE user_id = ? AND mode_name = ?", userID, strings.ToLower(modeName)) + require.NoError(t, err) + assert.Equal(t, 1, count, "Only the '0000000000' contact should remain") + + var phone string + // Ensure the phone number that remains is the one not in the mocked group + err = testDB.Get(&phone, "SELECT phone_number FROM autoreply_modes WHERE user_id = ? AND mode_name = ? AND phone_number = '0000000000'", userID, strings.ToLower(modeName)) + require.NoError(t, err, "The non-group contact '0000000000' should still exist") + assert.Equal(t, "0000000000", phone) + }, + }, + { + name: "Token not configured for user", + userID: testUserID2, + userToken: testUserToken2, + payload: ContactGroupDeleteRequest{ModeName: "AnyMode", GroupName: "AnyGroup"}, + expectedStatus: http.StatusForbidden, + expectedBodyRegex: `"error":"Google Contacts API token not configured`, + }, + { + name: "Invalid ModeName for delete", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "Invalid!", GroupName: "AnyGroup"}, + expectedStatus: http.StatusBadRequest, + expectedBodyRegex: `"error":"Invalid ModeName: must be alphanumeric"`, + }, + { + name: "fetchContactsFromGoogleGroup returns error on delete", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "ErrorTest", GroupName: "errorgroup"}, + expectedStatus: http.StatusInternalServerError, + expectedBodyRegex: `"error":"Failed to process contact group for deletion"`, + }, + { + name: "Delete with empty contact group", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "AnyMode", GroupName: "emptygroup"}, + expectedStatus: http.StatusOK, // Adjusted: 200 OK with detail message + expectedBodyRegex: `No contacts found in group 'emptygroup' to process for deletion.`, + }, + { + name: "Google API UNAUTHENTICATED error on delete", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "AuthTestDelete", GroupName: "auth_error_group_delete"}, + expectedStatus: http.StatusForbidden, + expectedBodyRegex: `"error":"Failed to authenticate with Google Contacts API. Please check your token or re-authenticate via /autoreply/contactgroupauth."`, + }, + { + name: "Google API PERMISSION_DENIED error on delete", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "PermTestDelete", GroupName: "perm_denied_group_delete"}, + expectedStatus: http.StatusForbidden, + expectedBodyRegex: `"error":"Failed to authenticate with Google Contacts API. Please check your token or re-authenticate via /autoreply/contactgroupauth."`, + }, + { + name: "Google API group not found error on delete", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "NotFoundTestDelete", GroupName: "non_existent_google_group_delete"}, + expectedStatus: http.StatusNotFound, + expectedBodyRegex: `"error":"Specified contact group 'non_existent_google_group_delete' not found."`, + }, + { + name: "Google API generic error on delete", + userID: testUserID1, + userToken: testUserToken1, + payload: ContactGroupDeleteRequest{ModeName: "GenericErrorTestDelete", GroupName: "generic_google_api_error_group_delete"}, + expectedStatus: http.StatusInternalServerError, + expectedBodyRegex: `"error":"Error processing contacts from Google group for deletion."`, + }, + } + // Store original fetch function to restore after tests + originalFetchContactsFunc := fetchContactsFromGoogleGroupFunc + defer func() { fetchContactsFromGoogleGroupFunc = originalFetchContactsFunc }() + + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup mock for fetchContactsFromGoogleGroupFunc for delete tests + fetchContactsFromGoogleGroupFunc = func(authToken string, groupName string, forUserLog string) ([]map[string]string, error) { + if groupName == "Default Google Group" { // Used in "Successful delete - default group" + return []map[string]string{ + {"name": "Test Contact 1", "phoneNumber": "+11234567890"}, + {"name": "Test Contact 2", "phoneNumber": "9876543210"}, + {"name": "Test Contact 3", "phoneNumber": "invalid-number"}, + {"name": "Test Contact 4", "phoneNumber": "+44-20-1234-5678"}, + {"name": "Test Contact 5", "phoneNumber": "12345"}, + {"name": "Test Contact 6", "phoneNumber": ""}, + }, nil + } + if groupName == "emptygroup" { + return []map[string]string{}, nil + } + if groupName == "auth_error_group_delete" { + return nil, errors.New("Google API error: Some message (Status: UNAUTHENTICATED)") + } + if groupName == "perm_denied_group_delete" { + return nil, errors.New("Google API error: Some message (Status: PERMISSION_DENIED)") + } + if groupName == "non_existent_google_group_delete" { + return nil, fmt.Errorf("contact group '%s' not found for user %s", groupName, forUserLog) + } + if groupName == "generic_google_api_error_group_delete" { + return nil, errors.New("some other Google API error for delete") + } + // This case is for "fetchContactsFromGoogleGroup returns error on delete" + if groupName == "errorgroup" { + return nil, errors.New("simulated error fetching contacts from Google Group for delete") + } + return nil, fmt.Errorf("unexpected groupName in mock for delete: %s", groupName) + } + + + jsonBody, err := json.Marshal(tc.payload) + require.NoError(t, err) + + req := newAuthenticatedRequest(t, "DELETE", "/autoreply/contactgroup", bytes.NewBuffer(jsonBody), tc.userToken, tc.userID) + rr := httptest.NewRecorder() + testRouter.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + bodyString := rr.Body.String() + match, _ := regexp.MatchString(tc.expectedBodyRegex, bodyString) + assert.True(t, match, "Response body regex mismatch.\nExpected regex: %s\nGot: %s", tc.expectedBodyRegex, bodyString) + + if tc.dbChecks != nil { + tc.dbChecks(t, tc.userID, tc.payload.ModeName) + } + }) + } +} + + +// Helper to get string value from sql.NullString for assertions +func nullStringValue(ns sql.NullString) string { + if ns.Valid { + return ns.String + } + return "" // Or some other indicator for NULL if needed +} diff --git a/helpers.go b/helpers.go index 90763922..fea75a4d 100644 --- a/helpers.go +++ b/helpers.go @@ -1,26 +1,19 @@ package main import ( - "encoding/json" "fmt" "github.com/rs/zerolog/log" - "net/http" ) -func Find(slice []string, val string) bool { - for _, item := range slice { +// Find takes a slice of strings and looks for an element in it. If found it will +// return its key/index, otherwise it will return -1 and a bool of false. +func Find(slice []string, val string) (int, bool) { + for i, item := range slice { if item == val { - return true + return i, true } } - return false -} - -// Update entry in User map -func updateUserInfo(values interface{}, field string, value string) interface{} { - log.Debug().Str("field", field).Str("value", value).Msg("User info updated") - values.(Values).m[field] = value - return values + return -1, false } // webhook for regular messages @@ -74,11 +67,3 @@ func callHookFile(myurl string, payload map[string]string, id string, file strin return nil } - -func (s *server) respondWithJSON(w http.ResponseWriter, statusCode int, payload interface{}) { - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(payload); err != nil { - log.Error().Err(err).Msg("Failed to encode JSON response") - w.WriteHeader(http.StatusInternalServerError) - } -} diff --git a/main.go b/main.go index 729e2004..e8b65164 100755 --- a/main.go +++ b/main.go @@ -133,6 +133,7 @@ func init() { } func main() { + //zerolog.SetGlobalLevel(zerolog.Disabled) ex, err := os.Executable() if err != nil { log.Fatal().Err(err).Msg("Failed to get executable path") diff --git a/migrations.go b/migrations.go index eaea3c83..362a7581 100644 --- a/migrations.go +++ b/migrations.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/jmoiron/sqlx" + log "github.com/rs/zerolog/log" // Added this line ) type Migration struct { @@ -45,8 +46,105 @@ var migrations = []Migration{ Name: "change_id_to_string", UpSQL: changeIDToStringSQL, }, + { + ID: 4, + Name: "add_autoreplies_table", + UpSQL: addAutorepliesTableSQLPostgres, + }, + { + ID: 5, + Name: "add_google_contacts_auth_token_to_users", + UpSQL: "ALTER TABLE users ADD COLUMN IF NOT EXISTS google_contacts_auth_token TEXT NULLABLE;", // Primarily for PostgreSQL + }, + { + ID: 6, + Name: "add_autoreply_modes_table", + UpSQL: addAutoreplyModesTableSQLPostgres, + }, + { + ID: 7, + Name: "add_active_mode_table", + UpSQL: addActiveModeTableSQLPostgres, + }, } +const addActiveModeTableSQLPostgres = ` +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'active_mode') THEN + CREATE TABLE active_mode ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + mode_id INTEGER NULLABLE REFERENCES autoreply_modes(id) ON DELETE SET NULL + ); + END IF; +END $$; +` + +const addActiveModeTableSQLSQLite = ` +CREATE TABLE IF NOT EXISTS active_mode ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + mode_id INTEGER NULLABLE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(mode_id) REFERENCES autoreply_modes(id) ON DELETE SET NULL +); +` + +const addAutoreplyModesTableSQLPostgres = ` +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'autoreply_modes') THEN + CREATE TABLE autoreply_modes ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mode_name TEXT NOT NULL, + UNIQUE (user_id, mode_name) + ); + END IF; +END $$; +` + +const addAutoreplyModesTableSQLSQLite = ` +CREATE TABLE IF NOT EXISTS autoreply_modes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + mode_name TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE (user_id, mode_name) +); +` + +const addAutorepliesTableSQLPostgres = ` +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'autoreplies') THEN + CREATE TABLE autoreplies ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + phone_number TEXT NOT NULL, + reply_body TEXT NOT NULL, + last_sent_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, phone_number) + ); + END IF; +END $$; +` + +const addAutorepliesTableSQLSQLite = ` +CREATE TABLE IF NOT EXISTS autoreplies ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + reply_body TEXT NOT NULL, + last_sent_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, phone_number) +) +` + const changeIDToStringSQL = ` -- Migration to change ID from integer to random string DO $$ @@ -228,7 +326,37 @@ func applyMigration(db *sqlx.DB, migration Migration) error { } else { _, err = tx.Exec(migration.UpSQL) } - } else { + } else if migration.ID == 4 { + if db.DriverName() == "sqlite" { + err = createTableIfNotExistsSQLite(tx, "autoreplies", addAutorepliesTableSQLSQLite) + } else { + _, err = tx.Exec(migration.UpSQL) + } + } else if migration.ID == 5 { // Special handling for migration 5 + if db.DriverName() == "sqlite" { + log.Info().Msgf("Applying migration ID %d: %s (SQLite specific via helper)", migration.ID, migration.Name) + err = addColumnIfNotExistsSQLite(tx, "users", "google_contacts_auth_token", "TEXT NULLABLE") + } else { // For PostgreSQL (and potentially others if they support IF NOT EXISTS) + log.Info().Msgf("Applying migration ID %d: %s (PostgreSQL specific)", migration.ID, migration.Name) + _, err = tx.Exec(migration.UpSQL) + } + } else if migration.ID == 6 { // Create autoreply_modes table + if db.DriverName() == "sqlite" { + log.Info().Msgf("Applying migration ID %d: %s (SQLite specific via helper)", migration.ID, migration.Name) + err = createTableIfNotExistsSQLite(tx, "autoreply_modes", addAutoreplyModesTableSQLSQLite) + } else { + log.Info().Msgf("Applying migration ID %d: %s (PostgreSQL specific)", migration.ID, migration.Name) + _, err = tx.Exec(migration.UpSQL) + } + } else if migration.ID == 7 { // Create active_mode table + if db.DriverName() == "sqlite" { + log.Info().Msgf("Applying migration ID %d: %s (SQLite specific via helper)", migration.ID, migration.Name) + err = createTableIfNotExistsSQLite(tx, "active_mode", addActiveModeTableSQLSQLite) + } else { + log.Info().Msgf("Applying migration ID %d: %s (PostgreSQL specific)", migration.ID, migration.Name) + _, err = tx.Exec(migration.UpSQL) // UpSQL for migration 7 is PostgreSQL specific + } + } else { // Generic SQL execution for other migrations _, err = tx.Exec(migration.UpSQL) } diff --git a/routes.go b/routes.go index 12f81ad6..e8712c99 100644 --- a/routes.go +++ b/routes.go @@ -101,6 +101,9 @@ func (s *server) routes() { s.router.Handle("/chat/send/poll", c.Then(s.SendPoll())).Methods("POST") s.router.Handle("/chat/send/edit", c.Then(s.SendEditMessage())).Methods("POST") + // Register all autoreply related routes + registerAutoreplyRoutes(s, s.router, c) + s.router.Handle("/user/presence", c.Then(s.SendPresence())).Methods("POST") s.router.Handle("/user/info", c.Then(s.GetUser())).Methods("POST") s.router.Handle("/user/check", c.Then(s.CheckUser())).Methods("POST") diff --git a/static/api/spec.yml b/static/api/spec.yml index addbf9d6..75268434 100644 --- a/static/api/spec.yml +++ b/static/api/spec.yml @@ -8,6 +8,24 @@ info: schemes: - http +tags: + - name: Admin + description: Operations for user administration + - name: Session + description: Manage WhatsApp connection sessions (connect, disconnect, status, QR) + - name: Webhook + description: Configure and manage webhooks for incoming messages and events + - name: User + description: User-related operations (info, check, presence, avatar, contacts) + - name: Chat + description: Operations for sending and managing messages (text, media) + - name: Autoreply + description: Operations related to autoreply configuration, modes, and contact group integration. + - name: Group + description: Operations related to WhatsApp groups + - name: Newsletter + description: Operations related to WhatsApp newsletters + paths: /admin/users: get: @@ -1165,7 +1183,497 @@ paths: application/json: schema: example: { "code": 200, "data": { "Details": "Participants updated successfully" }, "success": true } + /chat/autoreply: + post: + tags: + - Autoreply + summary: Add an auto-reply + description: Adds a new auto-reply configuration for the authenticated user. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Phone: + type: string + example: "1234567890" + Body: + type: string + example: "I am currently out of office." + required: + - Phone + - Body + responses: + "201": + description: Auto-reply added successfully. + content: + application/json: + schema: + example: {"code": 201, "data": {"detail": "Auto-reply added successfully", "id": "unique-id-string"}, "success": true} + "400": + description: Bad Request (e.g., missing fields). + "401": + description: Unauthorized. + "409": + description: Conflict (auto-reply for this phone number already exists). + "500": + description: Internal Server Error. + delete: + tags: + - Autoreply + summary: Delete an auto-reply + description: Deletes an auto-reply configuration based on the phone number for the authenticated user. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Phone: + type: string + example: "1234567890" + required: + - Phone + responses: + "200": + description: Auto-reply deleted successfully. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "Auto-reply deleted successfully"}, "success": true} + "400": + description: Bad Request (e.g., missing phone number). + "401": + description: Unauthorized. + "404": + description: Auto-reply not found. + "500": + description: Internal Server Error. + get: + tags: + - Autoreply + summary: List configured auto-replies + description: Retrieves all auto-reply configurations for the authenticated user. + security: + - ApiKeyAuth: [] + responses: + "200": + description: A list of auto-reply configurations. + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/AutoReplyEntry' + "401": + description: Unauthorized + "500": + description: Internal Server Error + + /autoreply/mode: + post: + tags: + - Autoreply + summary: Add or Update Mode Autoreply + description: Adds a new autoreply entry for a specific mode or updates an existing one (based on ModeName and Phone). + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/ModeAutoreplyRequest' + responses: + "201": + description: Mode autoreply added or updated successfully. + content: + application/json: + schema: + example: {"code": 201, "data": {"detail": "Mode autoreply added/updated successfully"}, "success": true} + "400": + description: Bad Request (e.g., invalid ModeName, missing fields). + content: + application/json: + schema: + example: {"code": 400, "error": "Invalid ModeName: must be alphanumeric", "success": false} + "500": + description: Internal Server Error. + delete: + tags: + - Autoreply + summary: Delete Mode Autoreply + description: Deletes autoreply entries for a specific mode. If 'Phone' is provided, only that specific entry is deleted. Otherwise, all entries for the mode are deleted. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/ModeAutoreplyDeleteRequest' + responses: + "200": + description: Mode autoreply entries deleted successfully. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "1 autoreply entry(s) deleted for mode 'work'"}, "success": true} + "400": + description: Bad Request (e.g., invalid ModeName). + "500": + description: Internal Server Error. + get: + tags: + - Autoreply + summary: Get Mode Autoreplies + description: Retrieves autoreply entries. If 'modeName' query parameter is provided, filters by that mode. Otherwise, retrieves all mode autoreplies for the user. + security: + - ApiKeyAuth: [] + parameters: + - name: modeName + in: query + required: false + description: The name of the mode to filter autoreplies by (case-insensitive, alphanumeric). + schema: + type: string + example: "vacation" + responses: + "200": + description: A list of mode autoreply configurations. + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/ModeAutoreplyEntry' + example: {"code": 200, "data": [{"ModeName": "office", "Phone": "1234567890", "Message": "I am in office."}], "success": true} + "400": + description: Bad Request (e.g., invalid modeName format). + "500": + description: Internal Server Error. + + /autoreply/enablemode: + post: + tags: + - Autoreply + summary: Enable an Autoreply Mode + description: Activates a specific autoreply mode. This clears any existing active autoreplies and populates them with entries from the specified mode. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/EnableModeRequest' + responses: + "200": + description: Mode enabled successfully. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "Mode 'vacation' enabled successfully. 5 autoreplies activated."}, "success": true} + "400": + description: Bad Request (e.g., invalid ModeName). + "404": + description: Mode not found (no entries in autoreply_modes for the given ModeName). This might be implicitly handled by a 200 OK with "0 autoreplies activated". + "500": + description: Internal Server Error. + + /autoreply/disablemode: + post: + tags: + - Autoreply + summary: Disable an Autoreply Mode + description: Deactivates the specified autoreply mode if it is currently active. This clears active autoreplies and sets the current mode to none. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/DisableModeRequest' + responses: + "200": + description: Mode disabled successfully or was not active. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "Mode 'vacation' disabled successfully."}, "success": true} + "400": + description: Bad Request (e.g., invalid ModeName). + "500": + description: Internal Server Error. + + /autoreply/currentmode: + get: + tags: + - Autoreply + summary: Get Current Active Autoreply Mode + description: Retrieves the name of the currently active autoreply mode for the user. + security: + - ApiKeyAuth: [] + responses: + "200": + description: Successfully retrieved the current mode. + content: + application/json: + schema: + $ref: '#/definitions/CurrentModeResponse' + "500": + description: Internal Server Error. + + /autoreply/clearmode: + post: + tags: + - Autoreply + summary: Clear All Autoreply Modes + description: Deactivates any currently active autoreply mode and clears all entries from the active autoreply list for the user. It does not delete entries from `autoreply_modes`. + security: + - ApiKeyAuth: [] + responses: + "200": + description: All modes cleared and current mode deactivated successfully. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "All modes cleared and current mode deactivated successfully."}, "success": true} + "500": + description: Internal Server Error. + + /autoreply/contactgroupauth: + post: + tags: + - Autoreply + summary: Store Google Contacts API Auth Token + description: Stores or updates the Google OAuth2 access token for the authenticated user, enabling integration with Google Contacts. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/AuthTokenRequest' + responses: + "200": + description: Auth token stored successfully. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "Auth token stored successfully"}, "success": true} + "400": + description: Bad Request (e.g., missing AuthToken). + content: + application/json: + schema: + example: {"code": 400, "error": "Missing AuthToken in Payload", "success": false} + "500": + description: Internal Server Error. + + /autoreply/contactgroup: + post: + tags: + - Autoreply + summary: Add contacts from a Google Contacts group to an autoreply mode + description: Fetches contacts from a specified Google Contacts group (using a stored auth token) and adds/updates them in the specified autoreply mode with the given message. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/ContactGroupRequest' + responses: + "200": + description: Contacts processed and added/updated for the mode. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "3 contacts processed and added/updated for mode 'work'. 1 contacts skipped."}, "success": true} + "400": + description: Bad Request (e.g., missing fields, invalid ModeName). + "403": + description: Forbidden (Google Contacts API token not configured). + content: + application/json: + schema: + example: {"code": 403, "error": "Google Contacts API token not configured. Please use /autoreply/contactgroupauth.", "success": false} + "500": + description: Internal Server Error. + delete: + tags: + - Autoreply + summary: Remove contacts from a Google Contacts group from an autoreply mode + description: Fetches contacts from a specified Google Contacts group (using a stored auth token) and removes them from the specified autoreply mode. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/ContactGroupDeleteRequest' + responses: + "200": + description: Contacts processed for deletion from the mode. + content: + application/json: + schema: + example: {"code": 200, "data": {"detail": "3 contacts from group 'Friends' processed for deletion from mode 'weekend'. 2 entries actually deleted. 1 contacts skipped."}, "success": true} + "400": + description: Bad Request (e.g., missing fields, invalid ModeName). + "403": + description: Forbidden (Google Contacts API token not configured). + "500": + description: Internal Server Error. + definitions: + AuthTokenRequest: + type: object + required: + - AuthToken + properties: + AuthToken: + type: string + description: "User-provided Google OAuth2 Access Token." + example: "ya29.a0AfH6SM..." + AutoReplyEntry: # Existing definition, ensure new ones are placed correctly relative to it. + type: object + properties: + phone: + type: string + description: "The phone number for which the auto-reply is configured." + example: "1234567890" + body: + type: string + description: "The message body of the auto-reply." + example: "I'm currently unavailable. I'll get back to you soon." + last_sent_at: + type: string + format: date-time + description: "The timestamp when this auto-reply was last sent. Omitted if never sent or if the value is null." + example: "2023-10-27T10:30:00Z" + nullable: true + required: + - phone + - body + ContactGroupDeleteRequest: + type: object + required: + - ModeName + - GroupName + properties: + ModeName: + type: string + description: "Name of the mode (alphanumeric)." + example: "work" + GroupName: + type: string + description: "Name of the Google Contacts group whose members will be removed from the mode." + example: "Old Colleagues" + ContactGroupRequest: + type: object + required: + - ModeName + - GroupName + - Message + properties: + ModeName: + type: string + description: "Name of the mode to associate contacts with (alphanumeric)." + example: "work" + GroupName: + type: string + description: "Name of the Google Contacts group to fetch contacts from." + example: "Colleagues" + Message: + type: string + description: "Autoreply message for these contacts within the specified mode." + example: "Currently busy with work tasks." + ModeAutoreplyRequest: # Existing definition + type: object + required: + - ModeName + - Phone + - Message + properties: + ModeName: + type: string + description: "Name of the mode (alphanumeric)." + example: "work" + Phone: + type: string + description: "Target phone number for the autoreply." + example: "1234567890" + Message: + type: string + description: "Message to be sent as autoreply." + example: "I am currently in a meeting." + ModeAutoreplyDeleteRequest: + type: object + required: + - ModeName + properties: + ModeName: + type: string + description: "Name of the mode to delete entries from (alphanumeric)." + example: "work" + Phone: + type: string + description: "Optional. Target phone number. If provided, only this specific entry for the mode is deleted." + example: "1234567890" + EnableModeRequest: + type: object + required: + - ModeName + properties: + ModeName: + type: string + description: "Name of the mode to enable (alphanumeric)." + example: "vacation" + DisableModeRequest: + type: object + required: + - ModeName + properties: + ModeName: + type: string + description: "Name of the mode to disable (alphanumeric)." + example: "vacation" + ModeAutoreplyEntry: + type: object + properties: + ModeName: + type: string + description: "Name of the mode." + example: "office" + Phone: + type: string + description: "Target phone number." + example: "1234567890" + Message: + type: string + description: "Autoreply message content." + example: "Currently at the office." + CurrentModeResponse: + type: object + properties: + current_mode_name: + type: string + nullable: true + description: "Name of the currently active mode. Null if no mode is active." + example: "vacation" User: type: object properties: diff --git a/wmiau.go b/wmiau.go index 9ae54760..baf1cd85 100644 --- a/wmiau.go +++ b/wmiau.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/tls" + "database/sql" "encoding/base64" "encoding/json" "errors" @@ -14,6 +15,8 @@ import ( "strings" "time" + "go.mau.fi/whatsmeow/proto/waE2E" + "google.golang.org/protobuf/proto" "github.com/go-resty/resty/v2" "github.com/jmoiron/sqlx" "github.com/mdp/qrterminal/v3" @@ -83,11 +86,11 @@ func (s *server) connectOnStartup() { } } else { for _, arg := range eventarray { - if !Find(messageTypes, arg) { + if _, found := Find(supportedEventTypes, arg); !found { log.Warn().Str("Type", arg).Msg("Message type discarded") continue } - if !Find(subscribedEvents, arg) { + if _, found := Find(subscribedEvents, arg); !found { subscribedEvents = append(subscribedEvents, arg) } } @@ -625,6 +628,55 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { } } + // --- BEGIN AUTOREPLY LOGIC --- + userID := mycli.userID + senderJID := evt.Info.Sender + senderPhoneNumber := senderJID.User + + type autoReplyRule struct { + ReplyBody string `db:"reply_body"` + LastSentAt sql.NullTime `db:"last_sent_at"` + } + var rule autoReplyRule + + err := mycli.db.Get(&rule, "SELECT reply_body, last_sent_at FROM autoreplies WHERE user_id = $1 AND phone_number = $2", userID, senderPhoneNumber) + if err != nil { + if err == sql.ErrNoRows { + log.Info().Str("user_id", userID).Str("sender", senderPhoneNumber).Msg("No autoreply rule found for this sender.") + // No rule, proceed to normal webhook handling without autoreply + } else { + log.Error().Err(err).Str("user_id", userID).Str("sender", senderPhoneNumber).Msg("Failed to query autoreply rule.") + // DB error, proceed to normal webhook handling, maybe log an alert + } + } else { + // Rule found, check rate limit + currentTime := time.Now() + if rule.LastSentAt.Valid && currentTime.Sub(rule.LastSentAt.Time) < 5*time.Minute { + log.Info().Str("user_id", userID).Str("sender", senderPhoneNumber).Msg("Autoreply skipped due to 5-minute rate limit.") + } else { + // Rate limit check passed, send the autoreply + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String(rule.ReplyBody), + }, + } + msgID := mycli.WAClient.GenerateMessageID() + _, sendErr := mycli.WAClient.SendMessage(context.Background(), senderJID, msg, whatsmeow.SendRequestExtra{ID: msgID}) + if sendErr != nil { + log.Error().Err(sendErr).Str("user_id", userID).Str("sender", senderPhoneNumber).Msg("Failed to send autoreply message.") + } else { + log.Info().Str("user_id", userID).Str("sender", senderPhoneNumber).Str("reply_body", rule.ReplyBody).Msg("Autoreply message sent successfully.") + // Update last_sent_at in the database + updateSQL := "UPDATE autoreplies SET last_sent_at = $1 WHERE user_id = $2 AND phone_number = $3" + _, updateErr := mycli.db.Exec(updateSQL, currentTime, userID, senderPhoneNumber) + if updateErr != nil { + log.Error().Err(updateErr).Str("user_id", userID).Str("sender", senderPhoneNumber).Msg("Failed to update last_sent_at for autoreply.") + } + } + } + } + // --- END AUTOREPLY LOGIC --- + case *events.Receipt: postmap["type"] = "ReadReceipt" dowebhook = 1