commit 0453f7001d2ded373830e44beb74399078857ed8 Author: Sangeeth Sudheer Date: Thu Mar 27 12:31:23 2025 +0530 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0764bd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,macos,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=go,visualstudiocode,macos,linux + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,macos,linux + +## +## +## Custom +## +## + +whatsapp.db +auto-response-time-map.json \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..863ea7a --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +start: install-deps + go run cmd/main.go + +install-deps: + go install ./... + +.PHONY: start install-deps \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..18cce70 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" + + "git.sangeeth.dev/wa-autoresponder/internal" + + _ "github.com/mattn/go-sqlite3" +) + +var lastResponseTimeMap map[string]string = map[string]string{} + +func areSameDay(t1, t2 time.Time) bool { + y1, m1, d1 := t1.Date() + y2, m2, d2 := t2.Date() + + return y1 == y2 && m1 == m2 && d1 == d2 +} + +func main() { + // |------------------------------------------------------------------------------------------------------| + // | NOTE: You must also import the appropriate DB connector, e.g. github.com/mattn/go-sqlite3 for SQLite | + // |------------------------------------------------------------------------------------------------------| + autoResponderMessageBytes, err := os.ReadFile("message.md") + + if err != nil { + panic(fmt.Errorf("error reading message.md: %w", err)) + } + + autoResponderMessage := string(autoResponderMessageBytes) + + fmt.Println("Auto responder message body:") + fmt.Println(autoResponderMessage) + + dbLog := waLog.Stdout("Database", "DEBUG", true) + container, err := sqlstore.New("sqlite3", "file:whatsapp.db?_foreign_keys=on", dbLog) + if err != nil { + panic(err) + } + + // If you want multiple sessions, remember their JIDs and use .GetDevice(jid) or .GetAllDevices() instead. + deviceStore, err := container.GetFirstDevice() + if err != nil { + panic(err) + } + + clientLog := waLog.Stdout("Client", "DEBUG", true) + waClient := whatsmeow.NewClient(deviceStore, clientLog) + waClient.SendPresence(types.PresenceAvailable) + + client := internal.NewClient(waClient) + client.Register() + client.Connect() + + // Listen to Ctrl+C (you can also do something else that prevents the program from exiting) + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + <-c + + client.Disconnect() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba8c992 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module git.sangeeth.dev/wa-autoresponder + +go 1.24.1 + +require go.mau.fi/whatsmeow v0.0.0-20250326122532-6680c9a6e9a7 + +require ( + golang.org/x/term v0.30.0 // indirect + rsc.io/qr v0.2.0 // indirect +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + github.com/mdp/qrterminal v1.0.1 + github.com/mdp/qrterminal/v3 v3.2.1 + github.com/rs/zerolog v1.33.0 // indirect + go.mau.fi/libsignal v0.1.2 // indirect + go.mau.fi/util v0.8.6 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4eac073 --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c= +github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0= +go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE= +go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= +go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= +go.mau.fi/whatsmeow v0.0.0-20250326122532-6680c9a6e9a7 h1:vaErNnMdctFjxZ1AmnJGnRUFTx8vY3rydi+Ycy/0010= +go.mau.fi/whatsmeow v0.0.0-20250326122532-6680c9a6e9a7/go.mod h1:WNhj4JeQ6YR6dUOEiCXKqmE4LavSFkwRoKmu4atRrRs= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/client.go b/internal/client.go new file mode 100644 index 0000000..1778c33 --- /dev/null +++ b/internal/client.go @@ -0,0 +1,173 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "math/rand/v2" + "os" + "strconv" + "time" + + "github.com/mdp/qrterminal" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +const autoResponseTimeMapJsonFileName = "auto-response-time-map.json" + +type Client struct { + WAClient *whatsmeow.Client + message string + autoResponseTimeMap map[string]string +} + +func NewClient(waClient *whatsmeow.Client) *Client { + autoResponderMessageBytes, err := os.ReadFile("message.md") + + if err != nil { + panic(fmt.Errorf("error reading message.md: %w", err)) + } + + autoResponseTimeMap := map[string]string{} + + fileInfo, _ := os.Stat(autoResponseTimeMapJsonFileName) + + if fileInfo != nil { + if fileInfo.IsDir() { + panic(fmt.Errorf("expected %s to be a file, but found dir", autoResponseTimeMapJsonFileName)) + } + + bytes, err := os.ReadFile(autoResponseTimeMapJsonFileName) + + if err != nil { + panic(fmt.Errorf("error reading %s: %w", autoResponseTimeMapJsonFileName, err)) + } + + err = json.Unmarshal(bytes, &autoResponseTimeMap) + + if err != nil { + panic(err) + } + } + + return &Client{ + WAClient: waClient, + message: string(autoResponderMessageBytes), + autoResponseTimeMap: autoResponseTimeMap, + } +} + +func (client *Client) Register() { + client.WAClient.AddEventHandler(client.eventHandler) +} + +func (myClient *Client) Connect() { + client := myClient.WAClient + + if client.Store.ID == nil { + // No ID stored, new login + qrChan, _ := client.GetQRChannel(context.Background()) + err := client.Connect() + if err != nil { + panic(err) + } + for evt := range qrChan { + if evt.Event == "code" { + // Render the QR code here + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + // or just manually `echo 2@... | qrencode -t ansiutf8` in a terminal + fmt.Println("QR code:", evt.Code) + } else { + fmt.Println("Login event:", evt.Event) + } + } + } else { + // Already logged in, just connect + err := client.Connect() + if err != nil { + panic(err) + } + } +} + +func (client *Client) Disconnect() { + client.WAClient.Disconnect() +} + +func (client *Client) hasAutoRespondedWithinSameDay(userId string) bool { + if rawLastResponseTime, exists := client.autoResponseTimeMap[userId]; exists { + parsedLastResponseTime, error := time.Parse(time.RFC3339, rawLastResponseTime) + if error != nil { + fmt.Fprintf(os.Stderr, "Map has time stored in invalid format, expected RFC3339. Raw value is %+v\n", rawLastResponseTime) + return false + } + + // If we already responded today, not need to send the same spiel again + if AreSameDay(parsedLastResponseTime, time.Now()) { + fmt.Printf("Already responded to user %s, skipping\n", userId) + return true + } + } + + return false +} + +func (client *Client) updateAutoResponseTime(userId string) { + client.autoResponseTimeMap[userId] = time.Now().Format(time.RFC3339) + + bytes, err := json.Marshal(client.autoResponseTimeMap) + + if err != nil { + panic(err) + } + + if err = os.WriteFile(autoResponseTimeMapJsonFileName, bytes, 0660); err != nil { + panic(err) + } +} + +func (client *Client) eventHandler(evt interface{}) { + switch v := evt.(type) { + case *events.Message: + if v.Info.IsGroup { + return + } + + if v.Info.IsFromMe { + return + } + + chatUserId := v.Info.Chat.User + + // TODO: Ignore businesses + + if client.hasAutoRespondedWithinSameDay(chatUserId) { + fmt.Printf("Already responded to user %s, skipping\n", chatUserId) + return + } + + time.Sleep(2 * time.Duration(rand.IntN(3)) * time.Second) + + client.WAClient.SendChatPresence(v.Info.Chat, types.ChatPresenceComposing, types.ChatPresenceMediaText) + + time.Sleep(2 * time.Duration(rand.IntN(3)) * time.Second) + + client.WAClient.SendMessage( + context.Background(), + v.Info.Chat, + &waE2E.Message{ + Conversation: proto.String(client.message + "\n\nIgnore this random number: `" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "`"), + }, + ) + + client.WAClient.SendChatPresence(v.Info.Chat, types.ChatPresencePaused, types.ChatPresenceMediaText) + + fmt.Printf("Sent autoresponder message to user %s\n", chatUserId) + + client.updateAutoResponseTime(chatUserId) + } +} diff --git a/internal/utils.go b/internal/utils.go new file mode 100644 index 0000000..5f9e622 --- /dev/null +++ b/internal/utils.go @@ -0,0 +1,12 @@ +package internal + +import ( + "time" +) + +func AreSameDay(t1, t2 time.Time) bool { + y1, m1, d1 := t1.Date() + y2, m2, d2 := t2.Date() + + return y1 == y2 && m1 == m2 && d1 == d2 +} diff --git a/message.md b/message.md new file mode 100644 index 0000000..6cf27da --- /dev/null +++ b/message.md @@ -0,0 +1,3 @@ +Due to increasing privacy and security concerns around WhatsApp, I will not be preferring to respond to DMs here anymore (unless urgent and non-sensitive). Please switch to Signal to chat with me. I highly recommend doing the same for all your personal communications. You can find me by searching for my number or visiting https://sng.lt/signal + +I've penned my reasons and thoughts around the same here, which I highly recommend reading: https://sng.lt/prefer-signal \ No newline at end of file