You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

321 lines
9.5 KiB

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/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"
"strings"
"sync/atomic"
"time"
)
var (
cli *whatsmeow.Client
log waLog.Logger
historySyncID int32
startupTime = time.Now().Unix()
)
func SanitizePhone(phone *string) {
if phone != nil && len(*phone) > 0 && !strings.Contains(*phone, "@") {
if len(*phone) <= 15 {
*phone = fmt.Sprintf("%s@s.whatsapp.net", *phone)
} else {
*phone = fmt.Sprintf("%s@g.us", *phone)
}
}
}
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 ValidateJidWithLogin(waCli *whatsmeow.Client, phone string) (types.JID, error) {
MustLogin(waCli)
return ParseJID(phone)
}
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.AuthError("you are not connect to services server, please reconnect"))
} else if !waCli.IsLoggedIn() {
panic(pkgError.AuthError("you are not login to services server, please login"))
}
}
func handler(rawEvt interface{}) {
switch evt := rawEvt.(type) {
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")
}
if evt.IsViewOnce {
metaParts = append(metaParts, "ephemeral")
}
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 := DownloadImage(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()) {
_, _ = cli.SendMessage(context.Background(), evt.Info.Sender, "", &waProto.Message{Conversation: proto.String(config.WhatsappAutoReplyMessage)})
}
if config.WhatsappAutoReplyWebhook != "" && !isGroupJid(evt.Info.Chat.String()) {
if err := sendAutoReplyWebhook(evt); err != nil {
logrus.Error("Failed to send webhoook", err)
}
}
case *events.Receipt:
if evt.Type == events.ReceiptTypeRead || evt.Type == events.ReceiptTypeReadSelf {
log.Infof("%v was read by %s at %s", evt.MessageIDs, evt.SourceString(), evt.Timestamp)
} else if evt.Type == events.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)
}
}
func sendAutoReplyWebhook(evt *events.Message) error {
logrus.Info("Sending webhook to", config.WhatsappAutoReplyWebhook)
client := &http.Client{Timeout: 10 * time.Second}
imageMedia := evt.Message.GetImageMessage()
body := map[string]any{
"from": evt.Info.SourceString(),
"message": evt.Message.GetConversation(),
"image": imageMedia,
"video": evt.Message.GetVideoMessage(),
"audio": evt.Message.GetAudioMessage(),
"document": evt.Message.GetDocumentMessage(),
"location": evt.Message.GetLocationMessage(),
"sticker": evt.Message.GetStickerMessage(),
"live_location": evt.Message.GetLiveLocationMessage(),
"view_once": evt.Message.GetViewOnceMessage(),
"list": evt.Message.GetListMessage(),
"order": evt.Message.GetOrderMessage(),
"contact": evt.Message.GetContactMessage(),
"forwarded": evt.Message.GetGroupInviteMessage(),
}
if imageMedia != nil {
path, err := DownloadImage(config.PathMedia, imageMedia)
if err != nil {
return pkgError.WebhookError(fmt.Sprintf("Failed to download image: %v", err))
}
body["image"] = 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.WhatsappAutoReplyWebhook, 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")
resp, err := client.Do(req)
if err != nil {
return pkgError.WebhookError(fmt.Sprintf("error when submit webhook %v", err))
}
defer resp.Body.Close()
return nil
}
func isGroupJid(jid string) bool {
return strings.Contains(jid, "@g.us")
}
func DownloadImage(storageLocation string, image *waProto.ImageMessage) (path string, err error) {
data, err := cli.Download(image)
if err != nil {
return path, err
}
extensions, _ := mime.ExtensionsByType(image.GetMimetype())
path = fmt.Sprintf("%s/%d%s", storageLocation, time.Now().Unix(), extensions[0])
err = os.WriteFile(path, data, 0600)
if err != nil {
return path, err
}
return path, nil
}