package whatsapp import ( "bytes" "context" "encoding/json" "fmt" "github.com/aldinokemal/go-whatsapp-web-multidevice/config" "github.com/aldinokemal/go-whatsapp-web-multidevice/internal/websocket" pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" "github.com/google/uuid" "github.com/sirupsen/logrus" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/appstate" waProto "go.mau.fi/whatsmeow/binary/proto" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" "google.golang.org/protobuf/proto" "mime" "net/http" "os" "regexp" "strings" "sync/atomic" "time" ) var ( cli *whatsmeow.Client log waLog.Logger historySyncID int32 startupTime = time.Now().Unix() ) type ExtractedMedia struct { MediaPath string `json:"media_path"` MimeType string `json:"mime_type"` Caption string `json:"caption"` } type evtReaction struct { ID string `json:"id"` Message string `json:"message"` } type evtMessage struct { ID string `json:"id"` Text string `json:"text"` RepliedId string `json:"replied_id"` } func SanitizePhone(phone *string) { if phone != nil && len(*phone) > 0 && !strings.Contains(*phone, "@") { if len(*phone) <= 15 { *phone = fmt.Sprintf("%s%s", *phone, config.WhatsappTypeUser) } else { *phone = fmt.Sprintf("%s%s", *phone, config.WhatsappTypeGroup) } } } func GetPlatformName(deviceID int) string { switch deviceID { case 0: return "UNKNOWN" case 1: return "CHROME" case 2: return "FIREFOX" case 3: return "IE" case 4: return "OPERA" case 5: return "SAFARI" case 6: return "EDGE" case 7: return "DESKTOP" case 8: return "IPAD" case 9: return "ANDROID_TABLET" case 10: return "OHANA" case 11: return "ALOHA" case 12: return "CATALINA" case 13: return "TCL_TV" default: return "UNKNOWN" } } func ParseJID(arg string) (types.JID, error) { if arg[0] == '+' { arg = arg[1:] } if !strings.ContainsRune(arg, '@') { return types.NewJID(arg, types.DefaultUserServer), nil } else { recipient, err := types.ParseJID(arg) if err != nil { fmt.Printf("invalid JID %s: %v", arg, err) return recipient, pkgError.ErrInvalidJID } else if recipient.User == "" { fmt.Printf("invalid JID %v: no server specified", arg) return recipient, pkgError.ErrInvalidJID } return recipient, nil } } func IsOnWhatsapp(waCli *whatsmeow.Client, jid string) bool { // only check if the jid a user with @s.whatsapp.net if strings.Contains(jid, "@s.whatsapp.net") { data, err := waCli.IsOnWhatsApp([]string{jid}) if err != nil { panic(pkgError.InvalidJID(err.Error())) } for _, v := range data { if !v.IsIn { return false } } } return true } func ValidateJidWithLogin(waCli *whatsmeow.Client, jid string) (types.JID, error) { MustLogin(waCli) if !IsOnWhatsapp(waCli, jid) { return types.JID{}, pkgError.InvalidJID(fmt.Sprintf("Phone %s is not on whatsapp", jid)) } return ParseJID(jid) } func InitWaDB() *sqlstore.Container { // Running Whatsapp log = waLog.Stdout("Main", config.WhatsappLogLevel, true) dbLog := waLog.Stdout("Database", config.WhatsappLogLevel, true) storeContainer, err := sqlstore.New("sqlite3", fmt.Sprintf("file:%s/%s?_foreign_keys=off", config.PathStorages, config.DBName), dbLog) if err != nil { log.Errorf("Failed to connect to database: %v", err) panic(pkgError.InternalServerError(fmt.Sprintf("Failed to connect to database: %v", err))) } return storeContainer } func InitWaCLI(storeContainer *sqlstore.Container) *whatsmeow.Client { device, err := storeContainer.GetFirstDevice() if err != nil { log.Errorf("Failed to get device: %v", err) panic(err) } osName := fmt.Sprintf("%s %s", config.AppOs, config.AppVersion) store.DeviceProps.PlatformType = &config.AppPlatform store.DeviceProps.Os = &osName cli = whatsmeow.NewClient(device, waLog.Stdout("Client", config.WhatsappLogLevel, true)) cli.EnableAutoReconnect = true cli.AutoTrustIdentity = true cli.AddEventHandler(handler) return cli } func MustLogin(waCli *whatsmeow.Client) { if waCli == nil { panic(pkgError.InternalServerError("Whatsapp client is not initialized")) } if !waCli.IsConnected() { panic(pkgError.ErrNotConnected) } else if !waCli.IsLoggedIn() { panic(pkgError.ErrNotLoggedIn) } } func handler(rawEvt interface{}) { switch evt := rawEvt.(type) { case *events.DeleteForMe: log.Infof("Deleted message %s for %s", evt.MessageID, evt.SenderJID.String()) case *events.AppStateSyncComplete: if len(cli.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock { err := cli.SendPresence(types.PresenceAvailable) if err != nil { log.Warnf("Failed to send available presence: %v", err) } else { log.Infof("Marked self as available") } } case *events.PairSuccess: websocket.Broadcast <- websocket.BroadcastMessage{ Code: "LOGIN_SUCCESS", Message: fmt.Sprintf("Successfully pair with %s", evt.ID.String()), } case *events.LoggedOut: websocket.Broadcast <- websocket.BroadcastMessage{ Code: "LIST_DEVICES", Result: nil, } case *events.Connected, *events.PushNameSetting: if len(cli.Store.PushName) == 0 { return } // Send presence available when connecting and when the pushname is changed. // This makes sure that outgoing messages always have the right pushname. err := cli.SendPresence(types.PresenceAvailable) if err != nil { log.Warnf("Failed to send available presence: %v", err) } else { log.Infof("Marked self as available") } case *events.StreamReplaced: os.Exit(0) case *events.Message: metaParts := []string{fmt.Sprintf("pushname: %s", evt.Info.PushName), fmt.Sprintf("timestamp: %s", evt.Info.Timestamp)} if evt.Info.Type != "" { metaParts = append(metaParts, fmt.Sprintf("type: %s", evt.Info.Type)) } if evt.Info.Category != "" { metaParts = append(metaParts, fmt.Sprintf("category: %s", evt.Info.Category)) } if evt.IsViewOnce { metaParts = append(metaParts, "view once") } log.Infof("Received message %s from %s (%s): %+v", evt.Info.ID, evt.Info.SourceString(), strings.Join(metaParts, ", "), evt.Message) img := evt.Message.GetImageMessage() if img != nil { path, err := ExtractMedia(config.PathStorages, img) if err != nil { log.Errorf("Failed to download image: %v", err) } else { log.Infof("Image downloaded to %s", path) } } if config.WhatsappAutoReplyMessage != "" && !isGroupJid(evt.Info.Chat.String()) && !strings.Contains(evt.Info.SourceString(), "broadcast") { _, _ = cli.SendMessage(context.Background(), evt.Info.Sender, &waProto.Message{Conversation: proto.String(config.WhatsappAutoReplyMessage)}) } if config.WhatsappWebhook != "" && !strings.Contains(evt.Info.SourceString(), "broadcast") && !isFromMySelf(evt.Info.SourceString()) { if err := forwardToWebhook(evt); err != nil { logrus.Error("Failed forward to webhook", err) } } case *events.Receipt: if evt.Type == types.ReceiptTypeRead || evt.Type == types.ReceiptTypeReadSelf { log.Infof("%v was read by %s at %s", evt.MessageIDs, evt.SourceString(), evt.Timestamp) } else if evt.Type == types.ReceiptTypeDelivered { log.Infof("%s was delivered to %s at %s", evt.MessageIDs[0], evt.SourceString(), evt.Timestamp) } case *events.Presence: if evt.Unavailable { if evt.LastSeen.IsZero() { log.Infof("%s is now offline", evt.From) } else { log.Infof("%s is now offline (last seen: %s)", evt.From, evt.LastSeen) } } else { log.Infof("%s is now online", evt.From) } case *events.HistorySync: id := atomic.AddInt32(&historySyncID, 1) fileName := fmt.Sprintf("%s/history-%d-%s-%d-%s.json", config.PathStorages, startupTime, cli.Store.ID.String(), id, evt.Data.SyncType.String()) file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE, 0600) if err != nil { log.Errorf("Failed to open file to write history sync: %v", err) return } enc := json.NewEncoder(file) enc.SetIndent("", " ") err = enc.Encode(evt.Data) if err != nil { log.Errorf("Failed to write history sync: %v", err) return } log.Infof("Wrote history sync to %s", fileName) _ = file.Close() case *events.AppState: log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue) } } // forwardToWebhook is a helper function to forward event to webhook url func forwardToWebhook(evt *events.Message) error { logrus.Info("Forwarding event to webhook:", config.WhatsappWebhook) client := &http.Client{Timeout: 10 * time.Second} imageMedia := evt.Message.GetImageMessage() stickerMedia := evt.Message.GetStickerMessage() videoMedia := evt.Message.GetVideoMessage() audioMedia := evt.Message.GetAudioMessage() documentMedia := evt.Message.GetDocumentMessage() var message evtMessage message.Text = evt.Message.GetConversation() message.ID = evt.Info.ID if extendedMessage := evt.Message.ExtendedTextMessage.GetText(); extendedMessage != "" { message.Text = extendedMessage message.RepliedId = evt.Message.ExtendedTextMessage.ContextInfo.GetStanzaId() } var quotedmessage any if evt.Message.ExtendedTextMessage != nil && evt.Message.ExtendedTextMessage.ContextInfo != nil { if conversation := evt.Message.ExtendedTextMessage.ContextInfo.QuotedMessage.GetConversation(); conversation != "" { quotedmessage = conversation } } var forwarded bool if evt.Message.ExtendedTextMessage != nil && evt.Message.ExtendedTextMessage.ContextInfo != nil { forwarded = evt.Message.ExtendedTextMessage.ContextInfo.GetIsForwarded() } var waReaction evtReaction if reactionMessage := evt.Message.ReactionMessage; reactionMessage != nil { waReaction.Message = reactionMessage.GetText() waReaction.ID = reactionMessage.GetKey().GetId() } body := map[string]interface{}{ "audio": audioMedia, "contact": evt.Message.GetContactMessage(), "document": documentMedia, "forwarded": forwarded, "from": evt.Info.SourceString(), "image": imageMedia, "list": evt.Message.GetListMessage(), "live_location": evt.Message.GetLiveLocationMessage(), "location": evt.Message.GetLocationMessage(), "message": message, "order": evt.Message.GetOrderMessage(), "pushname": evt.Info.PushName, "quoted_message": quotedmessage, "reaction": waReaction, "sticker": stickerMedia, "video": videoMedia, "view_once": evt.Message.GetViewOnceMessage(), } if imageMedia != nil { path, err := ExtractMedia(config.PathMedia, imageMedia) if err != nil { return pkgError.WebhookError(fmt.Sprintf("Failed to download image: %v", err)) } body["image"] = path } if stickerMedia != nil { path, err := ExtractMedia(config.PathMedia, stickerMedia) if err != nil { return pkgError.WebhookError(fmt.Sprintf("Failed to download sticker: %v", err)) } body["sticker"] = path } if videoMedia != nil { path, err := ExtractMedia(config.PathMedia, videoMedia) if err != nil { return pkgError.WebhookError(fmt.Sprintf("Failed to download video: %v", err)) } body["video"] = path } if audioMedia != nil { path, err := ExtractMedia(config.PathMedia, audioMedia) if err != nil { return pkgError.WebhookError(fmt.Sprintf("Failed to download audio: %v", err)) } body["audio"] = path } if documentMedia != nil { path, err := ExtractMedia(config.PathMedia, documentMedia) if err != nil { return pkgError.WebhookError(fmt.Sprintf("Failed to download document: %v", err)) } body["document"] = path } postBody, err := json.Marshal(body) if err != nil { return pkgError.WebhookError(fmt.Sprintf("Failed to marshal body: %v", err)) } req, err := http.NewRequest(http.MethodPost, config.WhatsappWebhook, bytes.NewBuffer(postBody)) if err != nil { return pkgError.WebhookError(fmt.Sprintf("error when create http object %v", err)) } req.Header.Set("Content-Type", "application/json") if _, err = client.Do(req); err != nil { return pkgError.WebhookError(fmt.Sprintf("error when submit webhook %v", err)) } return nil } // isGroupJid is a helper function to check if the message is from group func isGroupJid(jid string) bool { return strings.Contains(jid, "@g.us") } // isFromMySelf is a helper function to check if the message is from my self (logged in account) func isFromMySelf(jid string) bool { return extractPhoneNumber(jid) == extractPhoneNumber(cli.Store.ID.String()) } // extractPhoneNumber is a helper function to extract the phone number from a JID func extractPhoneNumber(jid string) string { regex := regexp.MustCompile(`\d+`) // Find all matches of the pattern in the JID matches := regex.FindAllString(jid, -1) // The first match should be the phone number if len(matches) > 0 { return matches[0] } // If no matches are found, return an empty string return "" } // ExtractMedia is a helper function to extract media from whatsapp func ExtractMedia(storageLocation string, mediaFile whatsmeow.DownloadableMessage) (extractedMedia ExtractedMedia, err error) { if mediaFile == nil { logrus.Info("Skip download because data is nil") return extractedMedia, nil } data, err := cli.Download(mediaFile) if err != nil { return extractedMedia, err } switch media := mediaFile.(type) { case *waProto.ImageMessage: extractedMedia.MimeType = media.GetMimetype() extractedMedia.Caption = media.GetCaption() case *waProto.AudioMessage: extractedMedia.MimeType = media.GetMimetype() case *waProto.VideoMessage: extractedMedia.MimeType = media.GetMimetype() extractedMedia.Caption = media.GetCaption() case *waProto.StickerMessage: extractedMedia.MimeType = media.GetMimetype() case *waProto.DocumentMessage: extractedMedia.MimeType = media.GetMimetype() extractedMedia.Caption = media.GetCaption() } extensions, _ := mime.ExtensionsByType(extractedMedia.MimeType) extractedMedia.MediaPath = fmt.Sprintf("%s/%d-%s%s", storageLocation, time.Now().Unix(), uuid.NewString(), extensions[0]) err = os.WriteFile(extractedMedia.MediaPath, data, 0600) if err != nil { return extractedMedia, err } return extractedMedia, nil }