Spaces:
Running
Running
restructure repo into production layout; add whatsapp-service, tests, ddl, docs, scripts; scrub hardcoded secrets
83d6851 | package utils | |
| import ( | |
| "encoding/json" | |
| "fmt" | |
| "net" | |
| "net/http" | |
| "net/url" | |
| "runtime" | |
| "strconv" | |
| "strings" | |
| "time" | |
| "github.com/gomessguii/logger" | |
| "go.mau.fi/whatsmeow/proto/waCompanionReg" | |
| "go.mau.fi/whatsmeow/proto/waE2E" | |
| whatsmeow_types "go.mau.fi/whatsmeow/types" | |
| "golang.org/x/exp/rand" | |
| "golang.org/x/net/proxy" | |
| ) | |
| type Values struct { | |
| m map[string]string | |
| } | |
| type VCardStruct struct { | |
| FullName string `json:"fullName"` | |
| Organization string `json:"organization"` | |
| Phone string `json:"phone"` | |
| } | |
| func GenerateRandomString(length int) string { | |
| characters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" | |
| b := make([]byte, length) | |
| for i := range b { | |
| b[i] = characters[rand.Intn(len(characters))] | |
| } | |
| return string(b) | |
| } | |
| func Find(slice []string, val string) bool { | |
| for _, item := range slice { | |
| if item == val { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| // CreateJID creates a properly formatted WhatsApp JID from a number string | |
| // This function matches the TypeScript createJid functionality with enhanced validation | |
| func CreateJID(number string) (string, error) { | |
| if number == "" { | |
| return "", fmt.Errorf("number cannot be empty") | |
| } | |
| // Remove timestamp suffix if present | |
| // number = strings.Split(number, ":")[0] | |
| // Check if already a valid JID format | |
| if strings.Contains(number, "@g.us") || | |
| strings.Contains(number, "@s.whatsapp.net") || | |
| strings.Contains(number, "@lid") || | |
| strings.Contains(number, "@broadcast") || | |
| strings.Contains(number, "@newsletter") { | |
| return number, nil | |
| } | |
| // Clean the number | |
| number = strings.ReplaceAll(number, " ", "") | |
| number = strings.ReplaceAll(number, "+", "") | |
| number = strings.ReplaceAll(number, "(", "") | |
| number = strings.ReplaceAll(number, ")", "") | |
| number = strings.Split(number, ":")[0] | |
| // Check if it's a group by hyphen and length | |
| if strings.Contains(number, "-") && len(number) >= 24 { | |
| // Remove non-digit and non-hyphen characters | |
| groupID := strings.Map(func(r rune) rune { | |
| if (r >= '0' && r <= '9') || r == '-' { | |
| return r | |
| } | |
| return -1 | |
| }, number) | |
| return groupID + "@g.us", nil | |
| } | |
| // Check if it's a group by length (18+ digits) | |
| if len(number) >= 18 { | |
| // Remove non-digit and non-hyphen characters | |
| groupID := strings.Map(func(r rune) rune { | |
| if (r >= '0' && r <= '9') || r == '-' { | |
| return r | |
| } | |
| return -1 | |
| }, number) | |
| return groupID + "@g.us", nil | |
| } | |
| // Remove all non-numeric characters for phone numbers | |
| number = strings.Map(func(r rune) rune { | |
| if r >= '0' && r <= '9' { | |
| return r | |
| } | |
| return -1 | |
| }, number) | |
| if number == "" { | |
| return "", fmt.Errorf("invalid number format") | |
| } | |
| // Format MX (52) or AR (54) numbers | |
| number = formatMXOrARNumber(number) | |
| // Format BR (55) numbers | |
| number = formatBRNumber(number) | |
| // Add + prefix for international format | |
| if !strings.HasPrefix(number, "+") { | |
| number = "+" + number | |
| } | |
| return number + "@s.whatsapp.net", nil | |
| } | |
| // formatMXOrARNumber formats Mexican (52) or Argentine (54) numbers | |
| func formatMXOrARNumber(jid string) string { | |
| if len(jid) < 2 { | |
| return jid | |
| } | |
| countryCode := jid[:2] | |
| // Check if it's MX (52) or AR (54) | |
| if countryCode == "52" && len(jid) == 13 { | |
| // Mexico: remove 2 digits (positions 2-3) | |
| // 5215551234567 -> 52 + 551234567 = 52551234567 | |
| return countryCode + jid[4:] | |
| } else if countryCode == "54" && len(jid) == 13 { | |
| // Argentina: remove 1 digit (position 2) | |
| // 5411123456789 -> 54 + 11123456789 = 5411123456789 | |
| return countryCode + jid[3:] | |
| } | |
| return jid | |
| } | |
| // formatBRNumber formats Brazilian (55) numbers according to the mobile number rules | |
| func formatBRNumber(jid string) string { | |
| // Only process if it's exactly 13 digits and starts with "55" | |
| if len(jid) != 13 || !strings.HasPrefix(jid, "55") { | |
| return jid | |
| } | |
| // Extract DDD (area code) - should be between 11-99 for Brazil | |
| ddd := jid[2:4] | |
| // Convert DDD to integer for validation | |
| dddNum, err := strconv.Atoi(ddd) | |
| if err != nil { | |
| return jid | |
| } | |
| // Brazilian DDD codes are between 11-99, if it's outside this range, it's not Brazil | |
| if dddNum < 11 || dddNum > 99 { | |
| return jid | |
| } | |
| // Extract the first digit after DDD | |
| if len(jid) < 6 { | |
| return jid | |
| } | |
| firstDigit := jid[4:5] | |
| firstDigitNum, err := strconv.Atoi(firstDigit) | |
| if err != nil { | |
| return jid | |
| } | |
| // Check if it's a mobile number (9 prefix) and DDD >= 31 | |
| if firstDigitNum >= 7 && dddNum >= 31 { | |
| // Remove the 9 prefix for mobile numbers with DDD >= 31 | |
| return jid[:4] + jid[5:] | |
| } | |
| // Keep the number as is (landline or special case) | |
| return jid | |
| } | |
| // ParseJID parses a number string into a WhatsApp JID with validation | |
| func ParseJID(arg string) (whatsmeow_types.JID, bool) { | |
| if arg == "" { | |
| return whatsmeow_types.NewJID("", whatsmeow_types.DefaultUserServer), false | |
| } | |
| // Use CreateJID for consistent formatting | |
| jidString, err := CreateJID(arg) | |
| if err != nil { | |
| logger.LogWarn("Failed to create JID: %s", err.Error()) | |
| return whatsmeow_types.NewJID("", whatsmeow_types.DefaultUserServer), false | |
| } | |
| // Parse the formatted JID | |
| recipient, err := whatsmeow_types.ParseJID(jidString) | |
| if err != nil { | |
| logger.LogWarn("Invalid JID: %s", err.Error()) | |
| return recipient, false | |
| } | |
| if recipient.User == "" && !strings.Contains(jidString, "@broadcast") { | |
| logger.LogError("Invalid JID. No user specified: %s", jidString) | |
| return recipient, false | |
| } | |
| return recipient, true | |
| } | |
| // CanonicalJID returns a JID safe for RAW protocol nodes (chatstate / typing, | |
| // read receipts, presence subscribe, reactions, etc.). | |
| // | |
| // CreateJID intentionally prefixes phone numbers with "+" (e.g. | |
| // "+554187083284@s.whatsapp.net") to match the IsOnWhatsApp/display convention. | |
| // Message sending tolerates this because whatsmeow normalizes the JID during | |
| // usync/device resolution. RAW nodes are sent WITHOUT usync, so a malformed | |
| // "+JID" reaches the server and the node is silently dropped (e.g. the "typing" | |
| // indicator never reaches the recipient). WhatsApp user JIDs are digits-only, so | |
| // strip the leading "+" to get the canonical form. | |
| func CanonicalJID(jid whatsmeow_types.JID) whatsmeow_types.JID { | |
| jid.User = strings.TrimPrefix(jid.User, "+") | |
| return jid | |
| } | |
| func CreateHTTPProxy(httpHost, httpPort, user, password string) (func(*http.Request) (*url.URL, error), error) { | |
| address := fmt.Sprintf("http://%s:%s@%s:%s", user, password, httpHost, httpPort) | |
| parsed, err := url.Parse(address) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return http.ProxyURL(parsed), nil | |
| } | |
| func CreateSocks5Proxy(socks5Host, socks5Port, user, password string) (proxy.Dialer, error) { | |
| auth := &proxy.Auth{ | |
| User: user, | |
| Password: password, | |
| } | |
| dialer, err := proxy.SOCKS5("tcp", fmt.Sprintf("%s:%s", socks5Host, socks5Port), auth, proxy.Direct) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return dialer, nil | |
| // return func(req *http.Request) (*url.URL, error) { | |
| // host := req.URL.Host | |
| // if !strings.Contains(host, ":") { | |
| // host = fmt.Sprintf("%s:443", host) // Adiciona porta padrão 443 se não especificada | |
| // } | |
| // conn, err := dialer.Dial("tcp", host) | |
| // if err != nil { | |
| // return nil, err | |
| // } | |
| // defer conn.Close() | |
| // return nil, nil | |
| // }, nil | |
| } | |
| // NormalizeProxyProtocol returns the proxy protocol normalized to one of | |
| // http/https/socks5. If not supplied, it is inferred from the port — ports | |
| // 1080, 2080, and 42000-43000 map to socks5; everything else defaults to http. | |
| func NormalizeProxyProtocol(protocol, port string) string { | |
| normalized := strings.ToLower(strings.TrimSpace(protocol)) | |
| switch normalized { | |
| case "socks": | |
| return "socks5" | |
| case "http", "https", "socks5": | |
| return normalized | |
| } | |
| switch strings.TrimSpace(port) { | |
| case "1080", "2080": | |
| return "socks5" | |
| } | |
| portNum, err := strconv.Atoi(strings.TrimSpace(port)) | |
| if err == nil && portNum >= 42000 && portNum <= 43000 { | |
| return "socks5" | |
| } | |
| return "http" | |
| } | |
| // BuildProxyAddress builds a proxy URL string suitable for whatsmeow's | |
| // client.SetProxyAddress — it supports http, https, and socks5 with optional | |
| // basic auth credentials. | |
| func BuildProxyAddress(protocol, host, port, user, password string) (string, error) { | |
| if strings.TrimSpace(host) == "" { | |
| return "", fmt.Errorf("proxy host is required") | |
| } | |
| if strings.TrimSpace(port) == "" { | |
| return "", fmt.Errorf("proxy port is required") | |
| } | |
| normalizedProtocol := NormalizeProxyProtocol(protocol, port) | |
| if normalizedProtocol != "http" && normalizedProtocol != "https" && normalizedProtocol != "socks5" { | |
| return "", fmt.Errorf("unsupported proxy protocol %q", protocol) | |
| } | |
| proxyURL := &url.URL{ | |
| Scheme: normalizedProtocol, | |
| Host: net.JoinHostPort(strings.TrimSpace(host), strings.TrimSpace(port)), | |
| } | |
| if user != "" { | |
| if password != "" { | |
| proxyURL.User = url.UserPassword(user, password) | |
| } else { | |
| proxyURL.User = url.User(user) | |
| } | |
| } | |
| return proxyURL.String(), nil | |
| } | |
| func UpdateUserInfo(values interface{}, field string, value string) interface{} { | |
| v, ok := values.(Values) | |
| if !ok { | |
| logger.LogError("Failed to cast values to Values type") | |
| return values | |
| } | |
| logger.LogDebug("User info updated field: %s value: %s", field, value) | |
| v.m[field] = value | |
| return v | |
| } | |
| func TimestampToUnixInt(timestamp string) (int64, error) { | |
| layout := "2006-01-02 15:04:05" | |
| t, err := time.Parse(layout, timestamp) | |
| if err != nil { | |
| return 0, err | |
| } | |
| unixTimestamp := t.Unix() | |
| return unixTimestamp, nil | |
| } | |
| func GenerateVC(data VCardStruct) string { | |
| result := ` | |
| BEGIN:VCARD | |
| VERSION:3.0 | |
| FN:` + data.FullName + ` | |
| ORG:` + data.Organization + `; | |
| TEL;type=CELL;type=VOICE;waid=` + data.Phone + `:` + data.Phone + ` | |
| END:VCARD` | |
| return result | |
| } | |
| func GetObject(message []byte, keyFind string) string { | |
| var messageMap map[string]interface{} | |
| err := json.Unmarshal(message, &messageMap) | |
| if err != nil { | |
| logger.LogError("failed to unmarshal message: %s", err) | |
| return "" | |
| } | |
| for key, value := range messageMap { | |
| if key == keyFind { | |
| if captionStr, ok := value.(string); ok { | |
| return captionStr | |
| } | |
| } | |
| if nestedMap, ok := value.(map[string]interface{}); ok { | |
| nestedMapBytes, err := json.Marshal(nestedMap) | |
| if err != nil { | |
| logger.LogError("failed to marshal nestedMap: %s", err) | |
| continue | |
| } | |
| if caption := GetObject(nestedMapBytes, keyFind); caption != "" { | |
| return caption | |
| } | |
| } | |
| } | |
| return "" | |
| } | |
| func WhatsAppGetUserOS() string { | |
| switch runtime.GOOS { | |
| case "windows": | |
| return "Windows" | |
| case "darwin": | |
| return "macOS" | |
| default: | |
| return "Linux" | |
| } | |
| } | |
| func WhatsAppGetUserAgent(agentType string) waCompanionReg.DeviceProps_PlatformType { | |
| switch strings.ToLower(agentType) { | |
| case "desktop": | |
| return waCompanionReg.DeviceProps_DESKTOP | |
| case "mac": | |
| return waCompanionReg.DeviceProps_CATALINA | |
| case "android": | |
| return waCompanionReg.DeviceProps_ANDROID_AMBIGUOUS | |
| case "android-phone": | |
| return waCompanionReg.DeviceProps_ANDROID_PHONE | |
| case "andorid-tablet": | |
| return waCompanionReg.DeviceProps_ANDROID_TABLET | |
| case "ios-phone": | |
| return waCompanionReg.DeviceProps_IOS_PHONE | |
| case "ios-catalyst": | |
| return waCompanionReg.DeviceProps_IOS_CATALYST | |
| case "ipad": | |
| return waCompanionReg.DeviceProps_IPAD | |
| case "wearos": | |
| return waCompanionReg.DeviceProps_WEAR_OS | |
| case "ie": | |
| return waCompanionReg.DeviceProps_IE | |
| case "edge": | |
| return waCompanionReg.DeviceProps_EDGE | |
| case "chrome": | |
| return waCompanionReg.DeviceProps_CHROME | |
| case "safari": | |
| return waCompanionReg.DeviceProps_SAFARI | |
| case "firefox": | |
| return waCompanionReg.DeviceProps_FIREFOX | |
| case "opera": | |
| return waCompanionReg.DeviceProps_OPERA | |
| case "uwp": | |
| return waCompanionReg.DeviceProps_UWP | |
| case "aloha": | |
| return waCompanionReg.DeviceProps_ALOHA | |
| case "tv-tcl": | |
| return waCompanionReg.DeviceProps_TCL_TV | |
| default: | |
| return waCompanionReg.DeviceProps_UNKNOWN | |
| } | |
| } | |
| func GetMessageType(waMsg *waE2E.Message) string { | |
| switch { | |
| case waMsg == nil: | |
| return "ignore" | |
| case waMsg.Conversation != nil, waMsg.ExtendedTextMessage != nil: | |
| return "text" | |
| case waMsg.ImageMessage != nil: | |
| return fmt.Sprintf("image %s", waMsg.GetImageMessage().GetMimetype()) | |
| case waMsg.StickerMessage != nil: | |
| return fmt.Sprintf("sticker %s", waMsg.GetStickerMessage().GetMimetype()) | |
| case waMsg.VideoMessage != nil: | |
| return fmt.Sprintf("video %s", waMsg.GetVideoMessage().GetMimetype()) | |
| case waMsg.PtvMessage != nil: | |
| return fmt.Sprintf("round video %s", waMsg.GetPtvMessage().GetMimetype()) | |
| case waMsg.AudioMessage != nil: | |
| return fmt.Sprintf("audio %s", waMsg.GetAudioMessage().GetMimetype()) | |
| case waMsg.DocumentMessage != nil: | |
| return fmt.Sprintf("document %s", waMsg.GetDocumentMessage().GetMimetype()) | |
| case waMsg.ContactMessage != nil: | |
| return "contact" | |
| case waMsg.ContactsArrayMessage != nil: | |
| return "contact array" | |
| case waMsg.LocationMessage != nil: | |
| return "location" | |
| case waMsg.LiveLocationMessage != nil: | |
| return "live location start" | |
| case waMsg.GroupInviteMessage != nil: | |
| return "group invite" | |
| case waMsg.GroupMentionedMessage != nil: | |
| return "group mention" | |
| case waMsg.ScheduledCallCreationMessage != nil: | |
| return "scheduled call create" | |
| case waMsg.ScheduledCallEditMessage != nil: | |
| return "scheduled call edit" | |
| case waMsg.ReactionMessage != nil: | |
| if waMsg.ReactionMessage.GetText() == "" { | |
| return "reaction remove" | |
| } | |
| return "reaction" | |
| case waMsg.EncReactionMessage != nil: | |
| return "encrypted reaction" | |
| case waMsg.PollCreationMessage != nil || waMsg.PollCreationMessageV2 != nil || waMsg.PollCreationMessageV3 != nil: | |
| return "poll create" | |
| case waMsg.PollUpdateMessage != nil: | |
| return "poll update" | |
| case waMsg.ProtocolMessage != nil: | |
| switch waMsg.GetProtocolMessage().GetType() { | |
| case waE2E.ProtocolMessage_REVOKE: | |
| if waMsg.GetProtocolMessage().GetKey() == nil { | |
| return "ignore" | |
| } | |
| return "revoke" | |
| case waE2E.ProtocolMessage_MESSAGE_EDIT: | |
| return "edit" | |
| case waE2E.ProtocolMessage_EPHEMERAL_SETTING: | |
| return "disappearing timer change" | |
| case waE2E.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE, | |
| waE2E.ProtocolMessage_HISTORY_SYNC_NOTIFICATION, | |
| waE2E.ProtocolMessage_INITIAL_SECURITY_NOTIFICATION_SETTING_SYNC: | |
| return "ignore" | |
| default: | |
| return fmt.Sprintf("unknown_protocol_%d", waMsg.GetProtocolMessage().GetType()) | |
| } | |
| case waMsg.ButtonsMessage != nil: | |
| return "buttons" | |
| case waMsg.ButtonsResponseMessage != nil: | |
| return "buttons response" | |
| case waMsg.TemplateMessage != nil: | |
| return "template" | |
| case waMsg.HighlyStructuredMessage != nil: | |
| return "highly structured template" | |
| case waMsg.TemplateButtonReplyMessage != nil: | |
| return "template button reply" | |
| case waMsg.InteractiveMessage != nil: | |
| return "interactive" | |
| case waMsg.GetInteractiveResponseMessage() != nil: | |
| return "interactive response" | |
| case waMsg.ListMessage != nil: | |
| return "list" | |
| case waMsg.ProductMessage != nil: | |
| return "product" | |
| case waMsg.ListResponseMessage != nil: | |
| return "list response" | |
| case waMsg.OrderMessage != nil: | |
| return "order" | |
| case waMsg.InvoiceMessage != nil: | |
| return "invoice" | |
| case waMsg.BotInvokeMessage != nil: | |
| return "bot invoke" | |
| case waMsg.EventMessage != nil: | |
| return "event" | |
| case waMsg.EventCoverImage != nil: | |
| return "event cover image" | |
| case waMsg.EncEventResponseMessage != nil: | |
| return "ignore" // these are ignored for now as they're not meant to be shown as new messages | |
| //return "encrypted event response" | |
| case waMsg.CommentMessage != nil: | |
| return "comment" | |
| case waMsg.EncCommentMessage != nil: | |
| return "encrypted comment" | |
| case waMsg.NewsletterAdminInviteMessage != nil: | |
| return "newsletter admin invite" | |
| case waMsg.SecretEncryptedMessage != nil: | |
| return "secret encrypted" | |
| case waMsg.PollResultSnapshotMessage != nil: | |
| return "poll result snapshot" | |
| case waMsg.MessageHistoryBundle != nil: | |
| return "message history bundle" | |
| case waMsg.RequestPhoneNumberMessage != nil: | |
| return "request phone number" | |
| case waMsg.KeepInChatMessage != nil: | |
| return "keep in chat" | |
| case waMsg.StatusMentionMessage != nil: | |
| return "status mention" | |
| case waMsg.StickerPackMessage != nil: | |
| return "sticker pack" | |
| case waMsg.AlbumMessage != nil: | |
| return "album" // or maybe these should be ignored? | |
| case waMsg.SendPaymentMessage != nil, waMsg.RequestPaymentMessage != nil, | |
| waMsg.DeclinePaymentRequestMessage != nil, waMsg.CancelPaymentRequestMessage != nil, | |
| waMsg.PaymentInviteMessage != nil: | |
| return "payment" | |
| case waMsg.Call != nil: | |
| return "call" | |
| case waMsg.Chat != nil: | |
| return "chat" | |
| case waMsg.PlaceholderMessage != nil: | |
| return "placeholder" | |
| case waMsg.SenderKeyDistributionMessage != nil, waMsg.StickerSyncRmrMessage != nil: | |
| return "ignore" | |
| default: | |
| return "unknown" | |
| } | |
| } | |
| func GetStringValue(s *string) string { | |
| if s == nil { | |
| return "" | |
| } | |
| return *s | |
| } | |
| // PrepareNumbersForWhatsAppCheck prepares phone numbers for IsOnWhatsApp call | |
| // based on formatJid flag. This centralizes the logic used by both CheckUser and SendText. | |
| func PrepareNumbersForWhatsAppCheck(numbers []string, formatJid *bool) ([]string, error) { | |
| // Default formatJid to true if not specified | |
| shouldFormat := true | |
| if formatJid != nil { | |
| shouldFormat = *formatJid | |
| } | |
| var phoneNumbers []string | |
| if shouldFormat { | |
| // Normalize numbers using CreateJID for consistent formatting | |
| for _, number := range numbers { | |
| // First, extract the raw number if it's already a JID | |
| rawNumber := number | |
| if strings.Contains(number, "@s.whatsapp.net") { | |
| rawNumber = strings.Split(number, "@")[0] | |
| } | |
| // Use CreateJID to normalize the raw number format | |
| normalizedJID, err := CreateJID(rawNumber) | |
| if err != nil { | |
| // Continue with original number if normalization fails | |
| phoneNumbers = append(phoneNumbers, number) | |
| continue | |
| } | |
| // Extract the phone number part from the JID for IsOnWhatsApp call | |
| // e.g., "+5511999999999@s.whatsapp.net" -> "+5511999999999" (keep + for IsOnWhatsApp) | |
| if strings.Contains(normalizedJID, "@s.whatsapp.net") { | |
| phoneNumber := strings.Split(normalizedJID, "@")[0] | |
| phoneNumbers = append(phoneNumbers, phoneNumber) | |
| } else if strings.Contains(normalizedJID, "@g.us") || strings.Contains(normalizedJID, "@broadcast") || strings.Contains(normalizedJID, "@lid") { | |
| // For groups, broadcasts, and LIDs, use the full JID | |
| phoneNumbers = append(phoneNumbers, normalizedJID) | |
| } else { | |
| phoneNumbers = append(phoneNumbers, normalizedJID) | |
| } | |
| } | |
| } else { | |
| // Use numbers exactly as received (raw format) | |
| phoneNumbers = append(phoneNumbers, numbers...) | |
| } | |
| return phoneNumbers, nil | |
| } | |
| // PrepareNumberForWhatsAppCheck prepares a single phone number for IsOnWhatsApp call | |
| // This is used by SendText which works with single numbers | |
| func PrepareNumberForWhatsAppCheck(phone string, formatJid bool) (string, error) { | |
| formatJidPtr := &formatJid | |
| numbers, err := PrepareNumbersForWhatsAppCheck([]string{phone}, formatJidPtr) | |
| if err != nil { | |
| return "", err | |
| } | |
| if len(numbers) == 0 { | |
| return "", fmt.Errorf("no valid number processed") | |
| } | |
| return numbers[0], nil | |
| } | |