⚡ Powerful, customizable and easy to use Instagram dm bot. With TUI and Eletron.js GUI! Using Selenium webdriver and Yaml configuration files.
21 |
22 |
This project is under active development, there may be bugs or missing features. If you have any problem or would like to see a feature implemented, please, open an issue. This is essential so that we can continue to improve IGopher!
23 |
24 |
25 | ---
26 |
27 | > Disclaimer: This is a research project. I am in no way responsible for the use you made of this tool. In addition, I am not responsible for any sanctions and/or limitations imposed on your account after using this bot.
28 |
29 | ---
30 |
31 | ## Table of Contents
32 |
33 | - [Presentation](#presentation)
34 | - [Graphical User Interface](#graphical-user-interface)
35 | - [Terminal User Interface](#terminal-user-interface)
36 | - [Features](#features)
37 | - [Getting Started](#getting-started)
38 | - [From release](#from-release)
39 | - [From sources](#from-sources)
40 | - [Flags](#flags)
41 | - [Known Issues](#known-issues)
42 | - [Contributing](#-contributing)
43 | - [Author](#author)
44 | - [License](#-license)
45 |
46 | ## Presentation
47 |
48 | IGopher is a new Instagram automation tool that aims to simplify the deployment of such tools and make their use more pleasant thanks to a TUI (Terminal User Interface) as well as a GUI (Graphical User Interface) powered with Electron.js!
49 |
50 | ### Graphical User Interface
51 |
52 |
53 |
54 | A beautiful, cross-platform and easy to use interface! Build with Electron.js and go-astilectron.
55 |
56 |
57 | Come with **Hot Reload** functionality to apply configuration changes without restart !
58 | Bot stopping and hot reloading are actions safe by waiting bot idle to execute.
59 |
60 | ### Terminal User Interface
61 |
62 |
63 |
64 | Automatic user fetching and message sending!
65 |
66 |
67 | Thanks to the TUI you can easily use this tool on a not very powerful machine, in ssh, on a Vps or even on an operating system without graphical interface!
68 | The bot configuration is very easy thanks to the different configuration menus in the TUI. Parameters are managed and saved in Yaml files easy to edit manually!
69 | All dependencies are downloaded and managed automatically.
70 |
71 |
72 |
73 | Easily configurable and easy to use thanks to his TUI !
74 |
75 |
76 | ### Requirements
77 | - [Java 8 or 11](https://java.com/fr/download/) (incompatible with newer versions yet)
78 | - For Windows:
79 | - [Optionnal] [Windows Terminal](https://www.microsoft.com/fr-fr/p/windows-terminal/9n0dx20hk701?activetab=pivot:overviewtab) -> in order to have a best TUI experience
80 |
81 | ## Features
82 | - Selenium webdriver engine :stars:
83 | - Automatic dependencies downloading and installation :stars:
84 | - Automated IG connection & message sending :stars:
85 | - Users scrapping from ig user followers :stars:
86 | - Scheduler :stars:
87 | - Quotas & user blacklist modules :stars:
88 | - Human writing simulation :stars:
89 | - Fully and easily customizable through Yaml files or with TUI :stars:
90 | - TUI (Terminal User Interface) :stars:
91 | - GUI (Graphical User Interface) powered with Electron.js :stars:
92 | - Hot Reload functionality to apply configuration changes without restart !
93 | - Stop and Hot Reload are actions safe by waiting bot idle to execute !
94 | - Many more to come ! 🥳
95 |
96 | **Check this [Project](https://github.com/hbollon/igopher/projects/1) to see all planned features for this tool! Feel free to suggest additional features to implement! 🥳**
97 |
98 | ## Getting Started
99 |
100 | ### From release
101 |
102 | #### GUI version:
103 |
104 | 1. Download and install [Java 8 or 11](https://java.com/fr/download/) (needed for Selenium webdriver) and add them to your path (on Windows)
105 | 2. Download [lastest release](https://github.com/hbollon/igopher/releases/latest) GUI executable for your operating system
106 | 3. Move the executable to a dedicated folder (it will create folders/files)
107 | 4. Launch it
108 | - For the moment, on MacOS, you must move the .app to your Applications folder and execute the binary file located inside the .app one. It will be improved soon!
109 | 5. Configure the bot with your Instagram credentials and your desired scrapping and autodm settings.
110 | 6. You're ready! Just hit the "Launch" option on the dm automation page 🚀
111 | IGopher will download all needed dependencies automatically, don't panic if it seems stuck. I will implement a download monitoring view soon :smile:
112 |
113 | #### TUI version:
114 |
115 | 1. Download and install [Java 8 or 11](https://java.com/fr/download/) (needed for Selenium webdriver) and add them to your path (on Windows)
116 | 2. Download [lastest release](https://github.com/hbollon/igopher/releases/latest) TUI executable for your operating system
117 | 3. Move the executable to a dedicated folder (it will create folders/files)
118 | 4. Launch it:
119 | - On Windows, open a **Windows Terminal** in the folder (or powershell/cmd but the experience quality can be lower) and execute it: ```./tui.exe``` or just drag and drop tui.exe in your command prompt
120 | - On Linux or MacOS, open you favorite shell in the folder, allow it to be executed with ```chmod +x ./tui``` and launch it: ```./tui```
121 | 5. Configure the bot with your Instagram credentials and set your desired scrapping and autodm settings. To do that, you can use the TUI settings screen or directly edit the config.yaml file.
122 | 6. You're ready! Just hit the "Launch" option in the TUI main menu 🚀
123 |
124 | ### From sources
125 |
126 | #### GUI version:
127 |
128 | ##### With bundles
129 |
130 | 1. Download and install [Java 8 or 11](https://java.com/fr/download/) (needed for Selenium webdriver) and add them to your path (on Windows)
131 | 2. Install [Go](https://golang.org/doc/install) on your system
132 | 3. Download [lastest release](https://github.com/hbollon/igopher/releases/latest) source archive or clone the master branch
133 | 4. Launch ```bundle.sh``` script from the project root directory
134 | 5. Once done, you can find all generated executables in ```cmd/igopher/gui-bundle/output``` for all operating systems!
135 |
136 | ##### Without bundles
137 |
138 | 1. Download and install [Java 8 or 11](https://java.com/fr/download/) (needed for Selenium webdriver) and add them to your path (on Windows)
139 | 2. Install [Go](https://golang.org/doc/install) on your system
140 | 3. Download [lastest release](https://github.com/hbollon/igopher/releases/latest) source archive or clone the master branch
141 | 4. Launch it with this command: ```go run ./cmd/igopher/gui```
142 |
143 | #### TUI version:
144 |
145 | 1. Download and install [Java 8 or 11](https://java.com/fr/download/) (needed for Selenium webdriver) and add them to your path (on Windows)
146 | 2. Install [Go](https://golang.org/doc/install) on your system
147 | 3. Download [lastest release](https://github.com/hbollon/igopher/releases/latest) source archive or clone the master branch
148 | 4. Launch it with this command: ```go run ./cmd/igopher/tui```
149 | 5. Configure the bot with your Instagram credentials and set your desired scrapping and autodm settings. To do that, you can use the TUI settings screen or directly edit the config.yaml file.
150 | 6. You're ready! Just hit the "Launch" option in the TUI main menu 🚀
151 |
152 | ### Flags
153 |
154 | IGopher have a flags system for debuging or to enable system feature.
155 | You can activate them by adding them after the executable call, for exemple to activate headless mode:
156 | ```./tui --headless```
157 |
158 | There is the list of all available flags:
159 | ```
160 | --debug
161 | Display debug and selenium output
162 | --force-download
163 | Force redownload of all dependencies even if exists
164 | --headless
165 | Run WebDriver with frame buffer
166 | --ignore-dependencies
167 | Skip dependencies management
168 | --loglevel string
169 | Log level threshold (default "info")
170 | --port int
171 | Specify custom communication port (default 8080)
172 | ```
173 |
174 | You can recover this list by adding **--help** flag.
175 |
176 | ## Known Issues
177 |
178 | #### [GUI] Microsoft Smart Screen block IGopher.exe execution
179 |
180 | At the moment Microsoft Smart Screen block IGopher.exe from launching. To avoid that, you must whitelist IGopher.
181 | I'm currently investigating on this issue, I submitted my exe to Microsoft so we will see.
182 |
183 | #### [GUI] Running the .app on MacOs does nothing
184 |
185 | At the moment, you must move the .app to your Applications folder and run the binary file located in it.
186 | It can also block the execution since the app isn't signed yet. You can avoid it by launching it from terminal or by right clicking on it and open it.
187 |
188 | #### Javascript error just after bot launch
189 |
190 | This issue ofter happen with an incompatible Java version installed.
191 | Indeed, IGopher isn't compatible with versions of the JRE greater than 11 yet due to the use of Selenium 3.
192 |
193 | Working Java versions tested:
194 | - Windows: [Java 8](https://java.com/fr/download/)
195 | - Linux (Manjaro): **jre11-openjdk** -> `sudo pacman -S jre11-openjdk`
196 |
197 |
198 | **If you find other problems, please open an issue. This is essential so that we can continue to improve IGopher! :smile:**
199 |
200 | ## 🤝 Contributing
201 |
202 | Contributions are greatly appreciated!
203 |
204 | 1. Fork the project
205 | 2. Create your feature branch (```git checkout -b feature/AmazingFeature```)
206 | 3. Commit your changes (```git commit -m 'Add some amazing stuff'```)
207 | 4. Push to the branch (```git push origin feature/AmazingFeature```)
208 | 5. Create a new Pull Request
209 |
210 | Issues and feature requests are welcome!
211 | Feel free to check [issues page](https://github.com/hbollon/igopher/issues).
212 |
213 | ## Author
214 |
215 | 👤 **Hugo Bollon**
216 |
217 | * Github: [@hbollon](https://github.com/hbollon)
218 | * LinkedIn: [@Hugo Bollon](https://www.linkedin.com/in/hugobollon/)
219 | * Portfolio: [hugobollon.me](https://www.hugobollon.me)
220 |
221 | ## Show your support
222 |
223 | Give a ⭐️ if this project helped you!
224 |
225 | ## 📝 License
226 |
227 | This project is under [MIT](https://github.com/hbollon/igopher/blob/master/LICENSE.md) license.
228 |
--------------------------------------------------------------------------------
/cmd/igopher/gui-bundler/.gitignore:
--------------------------------------------------------------------------------
1 | bind.go.tmp
--------------------------------------------------------------------------------
/cmd/igopher/gui-bundler/bind.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | func Asset(name string) ([]byte, error) {
4 | return []byte{}, nil
5 | }
6 |
7 | func AssetDir(name string) ([]string, error) {
8 | return []string{}, nil
9 | }
10 |
11 | func RestoreAssets(dir, name string) error {
12 | return nil
13 | }
14 |
--------------------------------------------------------------------------------
/cmd/igopher/gui-bundler/bundler.json:
--------------------------------------------------------------------------------
1 | {
2 | "app_name": "IGopher",
3 | "version_astilectron": "0.46.0",
4 | "version_electron": "11.1.0",
5 | "icon_path_darwin": "resources/favicon.icns",
6 | "icon_path_linux": "resources/favicon.png",
7 | "icon_path_windows": "resources/favicon.ico",
8 | "resources_path": "resources/static/vue-igopher/dist",
9 | "environments": [{
10 | "arch": "amd64",
11 | "os": "darwin"
12 | },
13 | {
14 | "arch": "amd64",
15 | "os": "linux"
16 | },
17 | {
18 | "arch": "amd64",
19 | "os": "windows",
20 | "env": {
21 | "CC": "x86_64-w64-mingw32-gcc",
22 | "CXX": "x86_64-w64-mingw32-g++",
23 | "CGO_ENABLED": "1"
24 | }
25 | }
26 | ]
27 | }
--------------------------------------------------------------------------------
/cmd/igopher/gui-bundler/gui.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "path/filepath"
6 |
7 | "github.com/asticode/go-astikit"
8 | "github.com/asticode/go-astilectron"
9 | bootstrap "github.com/asticode/go-astilectron-bootstrap"
10 | "github.com/hbollon/igopher/internal/config"
11 | "github.com/hbollon/igopher/internal/gui"
12 | "github.com/hbollon/igopher/internal/logger"
13 | "github.com/sirupsen/logrus"
14 | )
15 |
16 | const (
17 | AppName = "IGopher"
18 | VersionAstilectron = "0.46.0"
19 | VersionElectron = "11.1.0"
20 | )
21 |
22 | func main() {
23 | flag.Parse()
24 | logger.InitLogger()
25 | config.CheckEnvironment()
26 | //defer engine.BotStruct.SeleniumStruct.CleanUp()
27 |
28 | if err := bootstrap.Run(bootstrap.Options{
29 | Asset: Asset,
30 | AssetDir: AssetDir,
31 | AstilectronOptions: astilectron.Options{
32 | AppName: AppName,
33 | AppIconDarwinPath: filepath.FromSlash("resources/favicon.icns"),
34 | AppIconDefaultPath: filepath.FromSlash("resources/favicon.png"),
35 | SingleInstance: true,
36 | VersionAstilectron: VersionAstilectron,
37 | VersionElectron: VersionElectron,
38 | },
39 | Debug: false,
40 | Logger: logrus.StandardLogger(),
41 | MenuOptions: []*astilectron.MenuItemOptions{},
42 | OnWait: func(a *astilectron.Astilectron, ws []*astilectron.Window, _ *astilectron.Menu, _ *astilectron.Tray, _ *astilectron.Menu) error {
43 | // Add message handler
44 | gui.HandleMessages(ws[0])
45 |
46 | return nil
47 | },
48 | RestoreAssets: RestoreAssets,
49 | ResourcesPath: "resources/static/vue-igopher/dist",
50 | Windows: []*bootstrap.Window{{
51 | Homepage: "index.html",
52 | Options: &astilectron.WindowOptions{
53 | BackgroundColor: astikit.StrPtr("#333"),
54 | Center: astikit.BoolPtr(true),
55 | Width: astikit.IntPtr(1400),
56 | Height: astikit.IntPtr(1000),
57 | },
58 | }},
59 | }); err != nil {
60 | logrus.Fatalf("running bootstrap failed: %v", err)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/cmd/igopher/gui-bundler/resources:
--------------------------------------------------------------------------------
1 | ../../../resources
--------------------------------------------------------------------------------
/cmd/igopher/gui/gui.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | /*
4 | This package aims to allow developers to run the GUI without having to bundle using resources directly.
5 | To execute it just run: go run ./cmd/igopher/gui
6 |
7 | For release purpose use gui-bundler package
8 | */
9 |
10 | import (
11 | "flag"
12 | "fmt"
13 | "path/filepath"
14 |
15 | "github.com/asticode/go-astikit"
16 | "github.com/asticode/go-astilectron"
17 | "github.com/hbollon/igopher/internal/config"
18 | "github.com/hbollon/igopher/internal/gui"
19 | "github.com/hbollon/igopher/internal/logger"
20 | log "github.com/sirupsen/logrus"
21 | )
22 |
23 | const (
24 | AppName = "IGopher"
25 | VersionAstilectron = "0.46.0"
26 | VersionElectron = "11.1.0"
27 | )
28 |
29 | func main() {
30 | flag.Parse()
31 | logger.InitLogger()
32 | config.CheckEnvironment()
33 | //defer engine.BotStruct.SeleniumStruct.CleanUp()
34 |
35 | var w *astilectron.Window
36 | // Create astilectron
37 | a, err := astilectron.New(log.StandardLogger(), astilectron.Options{
38 | AppName: "IGopher",
39 | AppIconDarwinPath: filepath.FromSlash("resources/favicon.icns"),
40 | AppIconDefaultPath: filepath.FromSlash("resources/favicon.png"),
41 | BaseDirectoryPath: "./lib/electron",
42 | SingleInstance: true,
43 | VersionAstilectron: VersionAstilectron,
44 | VersionElectron: VersionElectron,
45 | })
46 | if err != nil {
47 | log.Fatal(fmt.Errorf("main: creating astilectron failed: %w", err))
48 | }
49 | defer a.Close()
50 |
51 | // Handle signals
52 | a.HandleSignals()
53 |
54 | // Start
55 | if err = a.Start(); err != nil {
56 | log.Fatal(fmt.Errorf("main: starting astilectron failed: %w", err))
57 | }
58 |
59 | // New window
60 | if w, err = a.NewWindow("./resources/static/vue-igopher/dist/app/index.html", &astilectron.WindowOptions{
61 | Center: astikit.BoolPtr(true),
62 | Width: astikit.IntPtr(1400),
63 | Height: astikit.IntPtr(1000),
64 | }); err != nil {
65 | log.Fatal(fmt.Errorf("main: new window failed: %w", err))
66 | }
67 |
68 | // Create windows
69 | if err = w.Create(); err != nil {
70 | log.Fatal(fmt.Errorf("main: creating window failed: %w", err))
71 | }
72 | gui.HandleMessages(w)
73 |
74 | // Open dev tools panel if flag is set
75 | // if *flags.DevToolsFlag {
76 | w.OpenDevTools()
77 | // }
78 |
79 | // Blocking pattern
80 | a.Wait()
81 | }
82 |
--------------------------------------------------------------------------------
/cmd/igopher/tui/IGopherTUI_dev.go:
--------------------------------------------------------------------------------
1 | //go:build dev
2 |
3 | package main
4 |
5 | import (
6 | "flag"
7 |
8 | "github.com/hbollon/igopher/internal/automation"
9 | "github.com/hbollon/igopher/internal/config"
10 | "github.com/hbollon/igopher/internal/config/flags"
11 | "github.com/hbollon/igopher/internal/logger"
12 | "github.com/hbollon/igopher/internal/process"
13 | tui "github.com/hbollon/igopher/internal/tui"
14 | "github.com/hbollon/igopher/internal/utils"
15 | "github.com/sirupsen/logrus"
16 | )
17 |
18 | func init() {
19 | flags.Flags.BackgroundFlag = flag.Bool("background-task", false,
20 | "Run IGopher as background task with actual configuration (configure it normally and after re-run IGopher with this flag)")
21 | }
22 |
23 | func main() {
24 | flag.Parse()
25 | logger.InitLogger()
26 |
27 | // Initialize environment
28 | config.CheckEnvironment()
29 |
30 | alreadyRunning, _ := process.CheckIfAlreadyRunning()
31 | if *flags.Flags.BackgroundFlag {
32 | if alreadyRunning {
33 | logrus.Error("IGopher is already running! Kill it or close it through TUI interface and retry.")
34 | return
35 | }
36 | logrus.Debug("Successfully dump pid to tmp file!")
37 | automation.LaunchBotTui()
38 | } else {
39 | // Clear terminal session
40 | utils.ClearTerminal()
41 |
42 | // Launch TUI
43 | execBot := tui.InitTui(alreadyRunning)
44 |
45 | // Launch bot if option selected
46 | if execBot {
47 | automation.LaunchBotTui()
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/cmd/igopher/tui/IGopherTUI_prod.go:
--------------------------------------------------------------------------------
1 | //go:build !dev
2 |
3 | package main
4 |
5 | import (
6 | "flag"
7 | "log"
8 | "os"
9 | "path/filepath"
10 |
11 | "github.com/hbollon/igopher/internal/automation"
12 | "github.com/hbollon/igopher/internal/config"
13 | "github.com/hbollon/igopher/internal/config/flags"
14 | "github.com/hbollon/igopher/internal/logger"
15 | "github.com/hbollon/igopher/internal/process"
16 | tui "github.com/hbollon/igopher/internal/tui"
17 | "github.com/hbollon/igopher/internal/utils"
18 | "github.com/sirupsen/logrus"
19 | )
20 |
21 | func init() {
22 | flags.Flags.BackgroundFlag = flag.Bool("background-task", false,
23 | "Run IGopher as background task with actual configuration (configure it normally and after re-run IGopher with this flag)")
24 | }
25 |
26 | // Change the current working directory by executable location one
27 | func changeWorkingDir() {
28 | dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
29 | if err != nil {
30 | log.Fatal(err)
31 | }
32 | err = os.Chdir(dir)
33 | if err != nil {
34 | log.Fatal(err)
35 | }
36 | }
37 |
38 | func main() {
39 | flag.Parse()
40 | changeWorkingDir()
41 | logger.InitLogger()
42 |
43 | // Initialize environment
44 | config.CheckEnvironment()
45 |
46 | alreadyRunning, _ := process.CheckIfAlreadyRunning()
47 | if *flags.Flags.BackgroundFlag {
48 | if alreadyRunning {
49 | logrus.Error("IGopher is already running! Kill it or close it through TUI interface and retry.")
50 | return
51 | }
52 | logrus.Debug("Successfully dump pid to tmp file!")
53 | automation.LaunchBotTui()
54 | } else {
55 | // Clear terminal session
56 | utils.ClearTerminal()
57 |
58 | // Launch TUI
59 | execBot := tui.InitTui(alreadyRunning)
60 |
61 | // Launch bot if option selected
62 | if execBot {
63 | automation.LaunchBotTui()
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/config/config.yaml.exemple:
--------------------------------------------------------------------------------
1 | # Instagram credentials
2 | account:
3 | username: "YOUR_IG_USERNAME"
4 | password: "YOUR_IG_PASSWORD"
5 |
6 | # User acquisition settings
7 | scrapper:
8 | config:
9 | src_accounts: # List of account where you want to fetch followers
10 | - ""
11 | fetch_quantity: 500
12 |
13 | # Bot configuration
14 | auto_dm:
15 | dm_templates:
16 | - "Hey ! What's up?"
17 | greeting:
18 | template: "Hello"
19 | activated: false
20 | activated: true
21 | quotas:
22 | dm_per_day: 50
23 | dm_per_hour: 5
24 | activated: true
25 | schedule: # 24h format
26 | begin_at: "8:00"
27 | end_at: "18:00"
28 | activated: true
29 | blacklist:
30 | activated: true
31 |
32 | # Webdriver/Selenium config
33 | webdriver:
34 | proxy:
35 | ip: ""
36 | port: 8080
37 | username: ""
38 | password: ""
39 | auth: false
40 | activated: false
--------------------------------------------------------------------------------
/data/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore all data files
2 | *.csv
3 | *.json
--------------------------------------------------------------------------------
/doc/IGopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/doc/IGopher.png
--------------------------------------------------------------------------------
/doc/gifs/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/doc/gifs/demo.gif
--------------------------------------------------------------------------------
/doc/gifs/demo_gui.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/doc/gifs/demo_gui.gif
--------------------------------------------------------------------------------
/doc/gifs/demo_tui.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/doc/gifs/demo_tui.gif
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/hbollon/igopher
2 |
3 | go 1.17
4 |
5 | require (
6 | cloud.google.com/go v0.41.0
7 | github.com/asticode/go-astikit v0.18.0
8 | github.com/asticode/go-astilectron v0.25.0
9 | github.com/asticode/go-astilectron-bootstrap v0.4.10
10 | github.com/banzaicloud/logrus-runtime-formatter v0.0.0-20190729070250-5ae5475bae5e
11 | github.com/charmbracelet/bubbles v0.7.6
12 | github.com/charmbracelet/bubbletea v0.12.2
13 | github.com/go-playground/validator/v10 v10.4.1
14 | github.com/google/go-github/v27 v27.0.4
15 | github.com/lucasb-eyer/go-colorful v1.0.3
16 | github.com/mitchellh/go-ps v1.0.0
17 | github.com/muesli/reflow v0.2.0
18 | github.com/muesli/termenv v0.7.4
19 | github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5
20 | github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18
21 | github.com/sirupsen/logrus v1.8.0
22 | github.com/tebeka/selenium v0.9.9
23 | github.com/vbauerster/mpb/v6 v6.0.2
24 | google.golang.org/api v0.7.0
25 | gopkg.in/yaml.v2 v2.3.0
26 | )
27 |
28 | require (
29 | github.com/VividCortex/ewma v1.1.1 // indirect
30 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
31 | github.com/akavel/rsrc v0.10.1 // indirect
32 | github.com/asticode/go-astilectron-bundler v0.7.12 // indirect
33 | github.com/asticode/go-bindata v1.0.0 // indirect
34 | github.com/atotto/clipboard v0.1.2 // indirect
35 | github.com/blang/semver v3.5.1+incompatible // indirect
36 | github.com/containerd/console v1.0.1 // indirect
37 | github.com/go-playground/locales v0.13.0 // indirect
38 | github.com/go-playground/universal-translator v0.17.0 // indirect
39 | github.com/golang/protobuf v1.3.1 // indirect
40 | github.com/google/go-querystring v1.0.0 // indirect
41 | github.com/googleapis/gax-go/v2 v2.0.5 // indirect
42 | github.com/hashicorp/golang-lru v0.5.1 // indirect
43 | github.com/leodido/go-urn v1.2.0 // indirect
44 | github.com/magefile/mage v1.11.0 // indirect
45 | github.com/mattn/go-isatty v0.0.12 // indirect
46 | github.com/mattn/go-runewidth v0.0.10 // indirect
47 | github.com/pkg/errors v0.9.1 // indirect
48 | github.com/rivo/uniseg v0.2.0 // indirect
49 | github.com/sam-kamerer/go-plister v1.2.0 // indirect
50 | go.opencensus.io v0.22.0 // indirect
51 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect
52 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect
53 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 // indirect
54 | golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46 // indirect
55 | golang.org/x/text v0.3.2 // indirect
56 | google.golang.org/appengine v1.6.1 // indirect
57 | google.golang.org/genproto v0.0.0-20190626174449-989357319d63 // indirect
58 | google.golang.org/grpc v1.21.1 // indirect
59 | )
60 |
--------------------------------------------------------------------------------
/internal/actions/dm.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/hbollon/igopher/internal/config/types"
7 | "github.com/hbollon/igopher/internal/simulation"
8 | "github.com/hbollon/igopher/internal/utils"
9 | "github.com/hbollon/igopher/internal/xpath"
10 | log "github.com/sirupsen/logrus"
11 | "github.com/tebeka/selenium"
12 | )
13 |
14 | // SendMessage navigate to Instagram direct message interface and send one to specified user
15 | // by simulating human typing
16 | func SendMessage(bot *types.IGopher, user, message string) (bool, error) {
17 | if bot.Scheduler.CheckTime() == nil && (!bot.Blacklist.Activated || !bot.Blacklist.IsBlacklisted(user)) {
18 | res, err := sendMessageWebDriver(bot, user, message)
19 | if res && err == nil {
20 | if bot.Quotas.Activated {
21 | bot.Quotas.AddDm()
22 | }
23 | if bot.Blacklist.Activated {
24 | bot.Blacklist.AddUser(user)
25 | }
26 | log.Info("Message successfully sent!")
27 | }
28 |
29 | return res, err
30 | }
31 | return false, nil
32 | }
33 |
34 | func sendMessageWebDriver(bot *types.IGopher, user, message string) (bool, error) {
35 | log.Infof("Send message to %s...", user)
36 | // Navigate to Instagram new direct message page
37 | if err := bot.SeleniumStruct.WebDriver.Get("https://www.instagram.com/direct/new/?hl=en"); err != nil {
38 | bot.SeleniumStruct.Fatal("Can't access to Instagram direct message redaction page! ", err)
39 | }
40 | utils.RandomSleepCustom(6, 10)
41 |
42 | // Type and select user to dm
43 | if find, err := bot.SeleniumStruct.WaitForElement(
44 | xpath.XPathSelectors["dm_user_search"], "xpath", 10); err == nil && find {
45 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["dm_user_search"], "xpath")
46 | log.Debug("Finded an retrieved user searchbar")
47 | if res := simulation.SimulateHandWriting(elem, user); !res {
48 | return false, errors.New("Error during user searching")
49 | }
50 | utils.RandomSleep()
51 | usernames, err := bot.SeleniumStruct.WebDriver.FindElements(selenium.ByXPATH,
52 | xpath.XPathSelectors["dm_profile_pictures_links"])
53 | if err != nil {
54 | return false, errors.New("Error during user selection")
55 | }
56 | usernames[0].Click()
57 | log.Debug("User to dm selected")
58 | } else {
59 | return false, errors.New("Error during user selection")
60 | }
61 |
62 | // Type and send message by simulating human writing
63 | if err := typeMessage(bot, message); err != nil {
64 | return false, errors.New("Error during message typing")
65 | }
66 | log.Debug("Message sended!")
67 |
68 | return true, nil
69 | }
70 |
71 | func typeMessage(bot *types.IGopher, message string) error {
72 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["dm_next_button"], "xpath", 5); err == nil && find {
73 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["dm_next_button"], "xpath")
74 | elem.Click()
75 | } else {
76 | log.Errorf("Error during message sending: %v", err)
77 | return err
78 | }
79 | utils.RandomSleep()
80 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["dm_placeholder"], "xpath", 5); err == nil && find {
81 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["dm_placeholder"], "xpath")
82 | if res := simulation.SimulateHandWriting(elem, message); !res {
83 | return errors.New("Error during message typing")
84 | }
85 | } else {
86 | log.Errorf("Error during message sending: %v", err)
87 | return err
88 | }
89 | utils.RandomSleep()
90 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["dm_send_button"], "xpath", 5); err == nil && find {
91 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["dm_send_button"], "xpath")
92 | elem.Click()
93 | } else {
94 | log.Errorf("Error during message sending: %v", err)
95 | return err
96 | }
97 | utils.RandomSleep()
98 |
99 | return nil
100 | }
101 |
--------------------------------------------------------------------------------
/internal/actions/login.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "github.com/hbollon/igopher/internal/config/types"
5 | "github.com/hbollon/igopher/internal/utils"
6 | "github.com/hbollon/igopher/internal/xpath"
7 | log "github.com/sirupsen/logrus"
8 | "github.com/tebeka/selenium"
9 | )
10 |
11 | // ConnectToInstagram get ig login webpage and connect user account
12 | func ConnectToInstagram(bot *types.IGopher) {
13 | connectToInstagramWebDriver(bot)
14 | }
15 |
16 | func connectToInstagramWebDriver(bot *types.IGopher) {
17 | log.Info("Connecting to Instagram account...")
18 | // Access Instagram url
19 | if err := bot.SeleniumStruct.WebDriver.Get("https://www.instagram.com/?hl=en"); err != nil {
20 | bot.SeleniumStruct.Fatal("Can't access to Instagram. ", err)
21 | }
22 | utils.RandomSleep()
23 | // Accept cookies if requested
24 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["login_accept_cookies"],
25 | "xpath", 10); err == nil && find {
26 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["login_accept_cookies"], "xpath")
27 | elem.Click()
28 | log.Debug("Cookies validation done!")
29 | } else {
30 | log.Info("Cookies validation button not found, skipping.")
31 | }
32 | utils.RandomSleep()
33 | // Access to login screen if needed
34 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["login_button"], "xpath", 10); err == nil && find {
35 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["login_button"], "xpath")
36 | elem.Click()
37 | log.Debug("Log in screen access done!")
38 | } else {
39 | log.Info("Login button not found, skipping.")
40 | }
41 | utils.RandomSleep()
42 | // Inject username and password to input fields and log in
43 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["login_username"], "name", 10); err == nil && find {
44 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["login_username"], "name")
45 | elem.SendKeys(bot.UserAccount.Username)
46 | log.Debug("Username injection done!")
47 | } else {
48 | bot.SeleniumStruct.Fatal("Exception during username inject: ", err)
49 | }
50 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["login_password"], "name", 10); err == nil && find {
51 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["login_password"], "name")
52 | elem.SendKeys(bot.UserAccount.Password)
53 | log.Debug("Password injection done!")
54 | } else {
55 | bot.SeleniumStruct.Fatal("Exception during password inject: ", err)
56 | }
57 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["login_alternate_button"], "xpath", 10); err == nil && find {
58 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["login_alternate_button"], "xpath")
59 | elem.Click()
60 | log.Debug("Sent login request")
61 | } else {
62 | bot.SeleniumStruct.Fatal("Log in button not found: ", err)
63 | }
64 | utils.RandomSleepCustom(10, 15)
65 | // Accept second cookies prompt if requested
66 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["login_alternate_button"], "xpath", 10); err == nil && find {
67 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["login_alternate_button"], "xpath")
68 | elem.Click()
69 | log.Debug("Second cookies validation done!")
70 | utils.RandomSleep()
71 | } else {
72 | log.Info("Second cookies validation button not found, skipping.")
73 | }
74 | // Check if login was successful
75 | if bot.SeleniumStruct.IsElementPresent(selenium.ByXPATH,
76 | xpath.XPathSelectors["login_information_saving"]) {
77 | log.Info("Login Successful!")
78 | } else {
79 | if err := bot.SeleniumStruct.WebDriver.Refresh(); err != nil {
80 | bot.SeleniumStruct.Fatal("Can't refresh page: ", err)
81 | }
82 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["login_information_saving"],
83 | "xpath", 10); err != nil || !find {
84 | log.Warnf("Instagram does not ask for informations saving or app download, the login process may have failed.")
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/internal/automation/bot.go:
--------------------------------------------------------------------------------
1 | package automation
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "math"
8 | "math/rand"
9 | "time"
10 |
11 | "github.com/hbollon/igopher/internal/actions"
12 | "github.com/hbollon/igopher/internal/config/flags"
13 | confdata "github.com/hbollon/igopher/internal/config/types"
14 | dep "github.com/hbollon/igopher/internal/dependency"
15 | "github.com/hbollon/igopher/internal/engine"
16 | "github.com/hbollon/igopher/internal/gui/comm"
17 | "github.com/hbollon/igopher/internal/gui/datatypes"
18 | "github.com/hbollon/igopher/internal/process"
19 | "github.com/hbollon/igopher/internal/scrapper"
20 | "github.com/sirupsen/logrus"
21 | log "github.com/sirupsen/logrus"
22 | )
23 |
24 | var (
25 | // BotStruct is the main struct instance used by this bot
26 | BotStruct confdata.IGopher
27 | ReloadCh chan bool
28 | HotReloadCh chan bool
29 | ExitedCh chan bool
30 | )
31 |
32 | // ErrStopBot is used to trigger bot stopping from some function
33 | var ErrStopBot = errors.New("Bot stop process triggered")
34 |
35 | func initClientConfig() *engine.ClientConfig {
36 | clientConfig := engine.CreateClientConfig()
37 | clientConfig.LogLevel, _ = log.ParseLevel(*flags.Flags.LogLevelFlag)
38 | clientConfig.ForceDependenciesDl = *flags.Flags.ForceDlFlag
39 | clientConfig.Debug = *flags.Flags.DebugFlag
40 | clientConfig.DevTools = *flags.Flags.DevToolsFlag
41 | clientConfig.IgnoreDependencies = *flags.Flags.IgnoreDependenciesFlag
42 | clientConfig.Headless = *flags.Flags.HeadlessFlag
43 |
44 | if *flags.Flags.PortFlag > math.MaxUint16 || *flags.Flags.PortFlag < 8080 {
45 | log.Warnf("Invalid port argument '%d'. Use default 8080.", *flags.Flags.PortFlag)
46 | } else {
47 | clientConfig.Port = uint16(*flags.Flags.PortFlag)
48 | }
49 |
50 | return clientConfig
51 | }
52 |
53 | // LaunchBotTui start dm bot on main goroutine
54 | func LaunchBotTui() {
55 | // Initialize client configuration
56 | var err error
57 | clientConfig := initClientConfig()
58 | BotStruct, err = confdata.ReadBotConfigYaml()
59 | if err != nil {
60 | logrus.Warn(err)
61 | }
62 |
63 | // Download dependencies
64 | if !clientConfig.IgnoreDependencies {
65 | dep.DownloadDependencies(true, false, clientConfig.ForceDependenciesDl)
66 | }
67 |
68 | // Initialize Selenium and WebDriver and defer their closing
69 | BotStruct.SeleniumStruct.InitializeSelenium(clientConfig)
70 | BotStruct.SeleniumStruct.InitChromeWebDriver()
71 | defer BotStruct.SeleniumStruct.CloseSelenium()
72 |
73 | process.DumpProcessPidToFile()
74 |
75 | rand.Seed(time.Now().Unix())
76 | if err = BotStruct.Scheduler.CheckTime(); err == nil {
77 | actions.ConnectToInstagram(&BotStruct)
78 | for {
79 | var users []string
80 | users, err = scrapper.FetchUsersFromUserFollowers(&BotStruct)
81 | if err != nil {
82 | BotStruct.SeleniumStruct.Fatal("Failed users fetching: ", err)
83 | }
84 | for _, username := range users {
85 | var res bool
86 | res, err = actions.SendMessage(&BotStruct, username, BotStruct.DmModule.DmTemplates[rand.Intn(len(BotStruct.DmModule.DmTemplates))])
87 | if !res || err != nil {
88 | log.Errorf("Error during message sending: %v", err)
89 | }
90 | }
91 | }
92 | } else {
93 | BotStruct.SeleniumStruct.Fatal("Error on bot launch: ", err)
94 | }
95 | }
96 |
97 | func checkBotChannels() bool {
98 | select {
99 | case <-BotStruct.HotReloadCallback:
100 | if err := BotStruct.HotReload(); err != nil {
101 | logrus.Errorf("Bot hot reload failed: %v", err)
102 | BotStruct.HotReloadCallback <- false
103 | } else {
104 | logrus.Info("Bot hot reload successfully.")
105 | BotStruct.HotReloadCallback <- true
106 | }
107 | break
108 | case <-BotStruct.ReloadCallback:
109 | logrus.Info("Bot reload successfully.")
110 | break
111 | case <-BotStruct.ExitCh:
112 | logrus.Info("Bot process successfully stopped.")
113 | return true
114 | default:
115 | break
116 | }
117 |
118 | return false
119 | }
120 |
121 | // Initialize client and bot configs, download dependencies,
122 | // launch Selenium instance and finally run dm bot routine
123 | func LaunchBot(ctx context.Context) {
124 | // Initialize client configuration
125 | var err error
126 | clientConfig := initClientConfig()
127 | BotStruct, err = confdata.ReadBotConfigYaml()
128 | if err != nil {
129 | logrus.Warn(err)
130 | }
131 | BotStruct.Running = true
132 |
133 | // Download dependencies
134 | if !clientConfig.IgnoreDependencies {
135 | dep.DownloadDependencies(true, false, clientConfig.ForceDependenciesDl)
136 | }
137 |
138 | // Initialize Selenium and WebDriver and defer their closing
139 | BotStruct.SeleniumStruct.InitializeSelenium(clientConfig)
140 | BotStruct.SeleniumStruct.InitChromeWebDriver()
141 | defer BotStruct.SeleniumStruct.CloseSelenium()
142 | defer BotStruct.SeleniumStruct.Proxy.StopForwarderProxy()
143 |
144 | // Creation of needed communication channels and deferring their closing
145 | ExitedCh = make(chan bool)
146 | defer close(ExitedCh)
147 | HotReloadCh = make(chan bool)
148 | defer close(HotReloadCh)
149 | ReloadCh = make(chan bool)
150 | defer close(ReloadCh)
151 |
152 | BotStruct.InfoCh = make(chan string)
153 | defer close(BotStruct.InfoCh)
154 | BotStruct.ErrCh = make(chan string)
155 | defer close(BotStruct.ErrCh)
156 | BotStruct.CrashCh = make(chan error)
157 | defer close(BotStruct.CrashCh)
158 | BotStruct.ExitCh = make(chan bool)
159 | defer close(BotStruct.ExitCh)
160 | BotStruct.ReloadCallback = make(chan bool)
161 | defer close(BotStruct.ReloadCallback)
162 | BotStruct.HotReloadCallback = make(chan bool)
163 | defer close(BotStruct.HotReloadCallback)
164 |
165 | process.DumpProcessPidToFile()
166 |
167 | // Start bot routine
168 | go func() {
169 | defer func() {
170 | if r := recover(); r != nil {
171 | log.Errorf("Unknown error: %v", r)
172 | comm.SendMessageToElectron(
173 | datatypes.MessageOut{
174 | Status: datatypes.ERROR,
175 | Msg: "bot crash",
176 | Payload: fmt.Errorf("Unknown error: %v", r),
177 | },
178 | )
179 | BotStruct.Running = false
180 | }
181 | }()
182 | rand.Seed(time.Now().Unix())
183 | if err = BotStruct.Scheduler.CheckTime(); err == nil {
184 | if exit := checkBotChannels(); exit {
185 | return
186 | }
187 | actions.ConnectToInstagram(&BotStruct)
188 | for {
189 | var users []string
190 | if exit := checkBotChannels(); exit {
191 | return
192 | }
193 | users, err = scrapper.FetchUsersFromUserFollowers(&BotStruct)
194 | if err != nil {
195 | BotStruct.CrashCh <- fmt.Errorf("Failed users fetching: %v. Check logs tab for more details", err)
196 | return
197 | }
198 | for _, username := range users {
199 | if exit := checkBotChannels(); exit {
200 | return
201 | }
202 | var res bool
203 | res, err = actions.SendMessage(&BotStruct, username, BotStruct.DmModule.DmTemplates[rand.Intn(len(BotStruct.DmModule.DmTemplates))])
204 | if !res || err != nil {
205 | BotStruct.ErrCh <- fmt.Sprintf("Error during message sending: %v", err)
206 | log.Errorf("Error during message sending: %v", err)
207 | }
208 | }
209 | }
210 | } else {
211 | if err == ErrStopBot {
212 | return
213 | }
214 | BotStruct.CrashCh <- err
215 | BotStruct.SeleniumStruct.Fatal("Error on bot launch: ", err)
216 | }
217 | }()
218 | var msg string
219 | for {
220 | select {
221 | case msg = <-BotStruct.InfoCh:
222 | log.Infof("infoCh: %s", msg)
223 | break
224 | case msg = <-BotStruct.ErrCh:
225 | log.Errorf("errCh: %s", msg)
226 | break
227 | case err := <-BotStruct.CrashCh:
228 | log.Errorf("crashCh: %v", err)
229 | comm.SendMessageToElectron(
230 | datatypes.MessageOut{
231 | Status: datatypes.ERROR,
232 | Msg: "bot crash",
233 | Payload: err.Error(),
234 | },
235 | )
236 | BotStruct.Running = false
237 | return
238 | case <-HotReloadCh:
239 | BotStruct.HotReloadCallback <- true
240 | if <-BotStruct.HotReloadCallback {
241 | HotReloadCh <- true
242 | } else {
243 | HotReloadCh <- false
244 | }
245 | break
246 | case <-ReloadCh:
247 | BotStruct.ReloadCallback <- true
248 | return
249 | case <-ctx.Done():
250 | BotStruct.ExitCh <- true
251 | ExitedCh <- true
252 | BotStruct.Running = false
253 | return
254 | default:
255 | break
256 | }
257 |
258 | if ws, err := BotStruct.SeleniumStruct.WebDriver.WindowHandles(); len(ws) == 0 || err != nil {
259 | BotStruct.SeleniumStruct.CleanUp()
260 | return
261 | }
262 | time.Sleep(10 * time.Millisecond)
263 | }
264 | }
265 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 | "io/ioutil"
6 | "os"
7 | "path/filepath"
8 |
9 | "github.com/go-playground/validator/v10"
10 | "github.com/hbollon/igopher/internal/config/types"
11 | "github.com/hbollon/igopher/internal/logger"
12 | "github.com/hbollon/igopher/internal/proxy"
13 | log "github.com/sirupsen/logrus"
14 | "gopkg.in/yaml.v2"
15 | )
16 |
17 | var (
18 | requiredDirectories = [...]string{"./lib", "./config"}
19 | )
20 |
21 | // CheckEnvironment check existence of sub-directories and files required
22 | // for the operation of the program and creates them otherwise
23 | func CheckEnvironment() {
24 | // Check and create directories
25 | for _, dir := range requiredDirectories {
26 | dir = filepath.FromSlash(dir)
27 | if _, err := os.Stat(dir); os.IsNotExist(err) {
28 | if err = os.Mkdir(dir, 0755); err != nil {
29 | log.Fatalf("Error during creation of '%s' sub-directory,"+
30 | " check root directory permissions or try to create it manually\nMkdir error:\n%v", dir, err)
31 | }
32 | }
33 | }
34 |
35 | // Check config.yaml existence
36 | if _, err := os.Stat(filepath.FromSlash("./config/config.yaml")); os.IsNotExist(err) {
37 | ExportConfig(ResetBotConfig())
38 | }
39 | }
40 |
41 | // CheckConfigValidity check bot config validity
42 | func CheckConfigValidity() error {
43 | config := ImportConfig()
44 | validate := validator.New()
45 | if err := validate.Struct(config.Account); err != nil {
46 | return errors.New("Invalid credentials format! Please check your settings")
47 | }
48 | if err := validate.Struct(config.SrcUsers); err != nil {
49 | return errors.New("Invalid scrapper configuration! Please check your settings")
50 | }
51 | if err := validate.Struct(config.AutoDm); err != nil {
52 | return errors.New("Invalid autodm module configuration! Please check your settings")
53 | }
54 |
55 | return nil
56 | }
57 |
58 | // ClearData remove all IGopher data sub-folder and their content.
59 | // It will recreate the necessary environment at the end no matter if an error has occurred or not.
60 | func ClearData() error {
61 | defer CheckEnvironment()
62 | defer logger.SetLoggerOutput()
63 | var err error
64 | dirs := []string{"./logs", "./config", "./data"}
65 | for _, dir := range dirs {
66 | err = os.RemoveAll(dir)
67 | if err != nil {
68 | return err
69 | }
70 | }
71 | return nil
72 | }
73 |
74 | // ImportConfig read config.yaml, parse it in BotConfigYaml instance and finally return it
75 | func ImportConfig() types.BotConfigYaml {
76 | var c types.BotConfigYaml
77 | file, err := ioutil.ReadFile(filepath.FromSlash("./config/config.yaml"))
78 | if err != nil {
79 | log.Fatalf("Error opening config file: %s", err)
80 | }
81 |
82 | err = yaml.Unmarshal(file, &c)
83 | if err != nil {
84 | log.Fatalf("Error during unmarshal config file: %s\n", err)
85 | }
86 |
87 | return c
88 | }
89 |
90 | // ExportConfig export BotConfigYaml instance to config.yaml config file
91 | func ExportConfig(c types.BotConfigYaml) {
92 | out, err := yaml.Marshal(&c)
93 | if err != nil {
94 | log.Fatalf("Error during marshal config file: %s\n", err)
95 | }
96 |
97 | err = ioutil.WriteFile(filepath.FromSlash("./config/config.yaml"), out, os.ModePerm)
98 | if err != nil {
99 | log.Fatalf("Error during config file writing: %s\n", err)
100 | }
101 | }
102 |
103 | // ResetBotConfig return default bot configuration instance
104 | func ResetBotConfig() types.BotConfigYaml {
105 | return types.BotConfigYaml{
106 | Account: types.AccountYaml{
107 | Username: "",
108 | Password: "",
109 | },
110 | SrcUsers: types.ScrapperYaml{
111 | Accounts: []string{""},
112 | Quantity: 500,
113 | },
114 | AutoDm: types.AutoDmYaml{
115 | DmTemplates: []string{"Hey ! What's up?"},
116 | Greeting: types.GreetingYaml{
117 | Template: "Hello",
118 | Activated: false,
119 | },
120 | Activated: true,
121 | },
122 | Quotas: types.QuotasYaml{
123 | DmDay: 50,
124 | DmHour: 5,
125 | Activated: true,
126 | },
127 | Schedule: types.ScheduleYaml{
128 | BeginAt: "08:00",
129 | EndAt: "18:00",
130 | Activated: true,
131 | },
132 | Blacklist: types.BlacklistYaml{
133 | Activated: true,
134 | },
135 | Selenium: types.SeleniumYaml{
136 | Proxy: proxy.Proxy{
137 | RemoteIP: "",
138 | RemotePort: 8080,
139 | RemoteUsername: "",
140 | RemotePassword: "",
141 | WithAuth: false,
142 | Enabled: false,
143 | },
144 | },
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/internal/config/flags/flags.go:
--------------------------------------------------------------------------------
1 | package flags
2 |
3 | import "flag"
4 |
5 | // Flags declarations
6 | var Flags = struct {
7 | // LogLevelFlag set loglevel threshold
8 | // If undefined or wrong set it to INFO level
9 | LogLevelFlag *string
10 |
11 | // ForceDlFlag force re-download of all dependencies
12 | ForceDlFlag *bool
13 |
14 | // DebugFlag set selenium debug mode and display its logging to stderr
15 | DebugFlag *bool
16 |
17 | // DevToolsFlag launch Electron gui with devtools openned
18 | DevToolsFlag *bool
19 |
20 | // IgnoreDependenciesFlag disable dependencies manager on startup
21 | IgnoreDependenciesFlag *bool
22 |
23 | // BackgroundFlag IGopher as background task with actual configuration and ignore TUI
24 | BackgroundFlag *bool
25 |
26 | // HeadlessFlag execute Selenium webdriver in headless mode
27 | HeadlessFlag *bool
28 |
29 | // PortFlag specifie custom communication port for Selenium and web drivers
30 | PortFlag *int
31 | }{
32 | LogLevelFlag: flag.String("loglevel", "info", "Log level threshold"),
33 | ForceDlFlag: flag.Bool("force-download", false, "Force redownload of all dependencies even if exists"),
34 | DebugFlag: flag.Bool("debug", false, "Display debug and selenium output"),
35 | DevToolsFlag: flag.Bool("dev-tools", false, "Launch Electron gui with dev tools openned"),
36 | IgnoreDependenciesFlag: flag.Bool("ignore-dependencies", false, "Skip dependencies management"),
37 | HeadlessFlag: flag.Bool("headless", false, "Run WebDriver with frame buffer"),
38 | PortFlag: flag.Int("port", 8080, "Specify custom communication port"),
39 | }
40 |
--------------------------------------------------------------------------------
/internal/config/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "path/filepath"
7 | "strings"
8 |
9 | "github.com/hbollon/igopher/internal/engine"
10 | "github.com/hbollon/igopher/internal/modules/blacklist"
11 | "github.com/hbollon/igopher/internal/modules/quotas"
12 | "github.com/hbollon/igopher/internal/modules/scheduler"
13 | "github.com/hbollon/igopher/internal/proxy"
14 | "github.com/sirupsen/logrus"
15 | "gopkg.in/yaml.v2"
16 | )
17 |
18 | // SplitStringSlice is a custom string slice type used to define a custom json unmarshal rule
19 | type SplitStringSlice []string
20 |
21 | // UnmarshalJSON custom rule for unmarshal string array from string by splitting it by ';'
22 | func (strSlice *SplitStringSlice) UnmarshalJSON(data []byte) error {
23 | var s string
24 | if err := json.Unmarshal(data, &s); err != nil {
25 | return err
26 | }
27 | *strSlice = strings.Split(s, ";")
28 | return nil
29 | }
30 |
31 | // IGopher struct store all bot and ig related configuration and modules instances.
32 | // Settings are readed from Yaml config files.
33 | type IGopher struct {
34 | // SeleniumStruct contain all selenium stuff and config
35 | SeleniumStruct engine.Selenium `yaml:"webdriver"`
36 | // User credentials
37 | UserAccount Account `yaml:"account"`
38 | // Automatic messages sending module
39 | DmModule AutoDM `yaml:"auto_dm"`
40 | // Quotas
41 | Quotas quotas.QuotaManager `yaml:"quotas"`
42 | // Scrapper
43 | ScrapperManager ScrapperConfig `yaml:"scrapper"`
44 | // Scheduler
45 | Scheduler scheduler.Manager `yaml:"schedule"`
46 | // Interracted users blacklist
47 | Blacklist blacklist.Manager `yaml:"blacklist"`
48 | // Channels
49 | InfoCh chan string `yaml:"-"`
50 | ErrCh chan string `yaml:"-"`
51 | CrashCh chan error `yaml:"-"`
52 | ExitCh chan bool `yaml:"-"`
53 | HotReloadCallback chan bool `yaml:"-"`
54 | ReloadCallback chan bool `yaml:"-"`
55 | // Running state
56 | Running bool `yaml:"-"`
57 | }
58 |
59 | // HotReload update bot config without stopping it
60 | // Some settings cannot be updated this way like account credentials
61 | func (bot *IGopher) HotReload() error {
62 | newConfig, err := ReadBotConfigYaml()
63 | if err != nil {
64 | return err
65 | }
66 |
67 | bot.DmModule = newConfig.DmModule
68 | bot.Quotas = newConfig.Quotas
69 | bot.ScrapperManager = newConfig.ScrapperManager
70 | bot.Scheduler = newConfig.Scheduler
71 | bot.Blacklist = newConfig.Blacklist
72 | return nil
73 | }
74 |
75 | // ReadBotConfigYaml read config yml file and initialize it for use with bot
76 | func ReadBotConfigYaml() (IGopher, error) {
77 | var c IGopher
78 | file, err := ioutil.ReadFile(filepath.FromSlash("./config/config.yaml"))
79 | if err != nil {
80 | logrus.Fatalf("Error opening config file: %s", err)
81 | }
82 |
83 | err = yaml.Unmarshal(file, &c)
84 | if err != nil {
85 | logrus.Fatalf("Error during unmarshal config file: %s\n", err)
86 | }
87 |
88 | c.Quotas.InitializeQuotaManager()
89 | err = c.Scheduler.InitializeScheduler()
90 | if err != nil {
91 | logrus.Errorf("Failed to initialize scheduler: %v", err)
92 | return c, err
93 | }
94 | err = c.Blacklist.InitializeBlacklist()
95 | if err != nil {
96 | logrus.Errorf("Failed to initialize blacklist: %v", err)
97 | return c, err
98 | }
99 | return c, nil
100 | }
101 |
102 | // ScrapperConfig store scrapper configuration for user fetching
103 | // It also store fetched usernames
104 | type ScrapperConfig struct {
105 | SrcAccounts []string `yaml:"src_accounts"`
106 | FetchedAccounts []string
107 | Quantity int `yaml:"fetch_quantity" validate:"numeric"`
108 | }
109 |
110 | // Account store personnal credentials
111 | type Account struct {
112 | Username string `json:"username" yaml:"username" validate:"required,min=1,max=30"`
113 | Password string `json:"password" yaml:"password" validate:"required,min=1"`
114 | }
115 |
116 | // AutoDM store messaging module configuration
117 | type AutoDM struct {
118 | // List of all availlables message templates
119 | DmTemplates []string `json:"dmTemplates" yaml:"dm_templates" validate:"required"`
120 | // Greeting module add a customized DM header with recipient username
121 | Greeting GreetingConfig `yaml:"greeting"`
122 | Activated bool `json:"dmActivated" yaml:"activated"`
123 | }
124 |
125 | // GreetingConfig store greeting configuration for AutoDM module
126 | type GreetingConfig struct {
127 | // Add a string before the username
128 | Template string `json:"greetingTemplate" yaml:"template" validate:"required"`
129 | Activated bool `json:"greetingActivated" yaml:"activated"`
130 | }
131 |
132 | /* Yaml */
133 |
134 | // BotConfigYaml is the raw representation of the yaml bot config file
135 | type BotConfigYaml struct {
136 | Account AccountYaml `json:"account" yaml:"account"`
137 | SrcUsers ScrapperYaml `json:"scrapper" yaml:"scrapper"`
138 | AutoDm AutoDmYaml `json:"auto_dm" yaml:"auto_dm"`
139 | Quotas QuotasYaml `json:"quotas" yaml:"quotas"`
140 | Schedule ScheduleYaml `json:"schedule" yaml:"schedule"`
141 | Blacklist BlacklistYaml `json:"blacklist" yaml:"blacklist"`
142 | Selenium SeleniumYaml `json:"webdriver" yaml:"webdriver"`
143 | }
144 |
145 | // AccountYaml is the yaml account configuration representation
146 | type AccountYaml struct {
147 | Username string `json:"username" yaml:"username" validate:"required,min=1,max=30"`
148 | Password string `json:"password" yaml:"password" validate:"required"`
149 | }
150 |
151 | // ScrapperYaml is the yaml user scrapping configuration representation
152 | type ScrapperYaml struct {
153 | Accounts SplitStringSlice `json:"srcUsers" yaml:"src_accounts" validate:"required"`
154 | Quantity int `json:"scrappingQuantity,string" yaml:"fetch_quantity" validate:"numeric,min=1"`
155 | }
156 |
157 | // AutoDmYaml is the yaml autodm module configuration representation
158 | type AutoDmYaml struct {
159 | DmTemplates SplitStringSlice `json:"dmTemplates" yaml:"dm_templates" validate:"required"`
160 | Greeting GreetingYaml `json:"greeting" yaml:"greeting"`
161 | Activated bool `json:"dmActivation,string" yaml:"activated"`
162 | }
163 |
164 | // GreetingYaml is the yaml dm greeting configuration representation
165 | type GreetingYaml struct {
166 | Template string `json:"greetingTemplate" yaml:"template"`
167 | Activated bool `json:"greetingActivation,string" yaml:"activated"`
168 | }
169 |
170 | // QuotasYaml is the yaml quotas module configuration representation
171 | type QuotasYaml struct {
172 | DmDay int `json:"dmDay,string" yaml:"dm_per_day" validate:"numeric,min=1"`
173 | DmHour int `json:"dmHour,string" yaml:"dm_per_hour" validate:"numeric,min=1"`
174 | Activated bool `json:"quotasActivation,string" yaml:"activated"`
175 | }
176 |
177 | // ScheduleYaml is the yaml scheduler module configuration representation
178 | type ScheduleYaml struct {
179 | BeginAt string `json:"beginAt" yaml:"begin_at" validate:"contains=:"`
180 | EndAt string `json:"endAt" yaml:"end_at" validate:"contains=:"`
181 | Activated bool `json:"scheduleActivation,string" yaml:"activated"`
182 | }
183 |
184 | // BlacklistYaml is the yaml blacklist module configuration representation
185 | type BlacklistYaml struct {
186 | Activated bool `json:"blacklistActivation,string" yaml:"activated"`
187 | }
188 |
189 | // SeleniumYaml is the yaml selenium configuration representation
190 | type SeleniumYaml struct {
191 | Proxy proxy.Proxy `json:"proxy" yaml:"proxy"`
192 | }
193 |
--------------------------------------------------------------------------------
/internal/engine/engine.go:
--------------------------------------------------------------------------------
1 | package engine
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "os/signal"
8 | "path/filepath"
9 | "runtime"
10 | "strings"
11 | "syscall"
12 | "time"
13 |
14 | "github.com/hbollon/igopher/internal/process"
15 | "github.com/hbollon/igopher/internal/proxy"
16 | "github.com/sirupsen/logrus"
17 | log "github.com/sirupsen/logrus"
18 | "github.com/tebeka/selenium"
19 | "github.com/tebeka/selenium/chrome"
20 | )
21 |
22 | const (
23 | locatorID = "ID"
24 | locatorName = "NAME"
25 | locatorXPath = "XPATH"
26 | locatorCSS = "CSS"
27 | )
28 |
29 | var (
30 | seleniumPath = filepath.FromSlash("./lib/selenium-server.jar")
31 | chromePath, chromeDriverPath, geckoDriverPath string
32 | )
33 |
34 | func init() {
35 | process.Init("./data/pid.txt")
36 | if runtime.GOOS == "windows" {
37 | geckoDriverPath = filepath.FromSlash("./lib/geckodriver.exe")
38 | chromeDriverPath = filepath.FromSlash("./lib/chromedriver.exe")
39 | chromePath = filepath.FromSlash("./lib/chrome-win/chrome.exe")
40 | } else if runtime.GOOS == "darwin" {
41 | geckoDriverPath = filepath.FromSlash("./lib/geckodriver")
42 | chromeDriverPath = filepath.FromSlash("./lib/chromedriver")
43 | chromePath = filepath.FromSlash("./lib/chrome-mac/Chromium.app/Contents/MacOS/Chromium")
44 | } else {
45 | geckoDriverPath = filepath.FromSlash("./lib/geckodriver")
46 | chromeDriverPath = filepath.FromSlash("./lib/chromedriver")
47 | chromePath = filepath.FromSlash("./lib/chrome-linux/chrome")
48 | }
49 | }
50 |
51 | // Selenium instance and opts
52 | type Selenium struct {
53 | Instance *selenium.Service
54 | Config *ClientConfig
55 | Opts []selenium.ServiceOption
56 | Proxy proxy.Proxy `yaml:"proxy"`
57 | WebDriver selenium.WebDriver
58 | SigTermRoutineExit chan bool
59 | }
60 |
61 | // ClientConfig struct centralize all client configuration and flags.
62 | // Inizialized at program startup, not safe to modify this after.
63 | type ClientConfig struct {
64 | // LogLevel set loglevel threshold
65 | // If undefined or wrong set it to INFO level
66 | LogLevel logrus.Level
67 | // ForceDependenciesDl force re-download of all dependencies
68 | ForceDependenciesDl bool
69 | // Debug set selenium debug mode and display its logging to stderr
70 | Debug bool
71 | //DevTools launch Electron gui with devtools openned
72 | DevTools bool
73 | // IgnoreDependencies disable dependencies manager on startup
74 | IgnoreDependencies bool
75 | // Headless execute Selenium webdriver in headless mode
76 | Headless bool
77 | // Port : communication port
78 | Port uint16
79 | }
80 |
81 | // CreateClientConfig create default ClientConfig instance and return a pointer on it
82 | func CreateClientConfig() *ClientConfig {
83 | return &ClientConfig{
84 | LogLevel: logrus.InfoLevel,
85 | ForceDependenciesDl: false,
86 | Debug: false,
87 | IgnoreDependencies: false,
88 | Headless: false,
89 | Port: 8080,
90 | }
91 | }
92 |
93 | // InitializeSelenium start a Selenium WebDriver server instance
94 | // (if one is not already running).
95 | func (s *Selenium) InitializeSelenium(clientConfig *ClientConfig) {
96 | var err error
97 | s.Config = clientConfig
98 |
99 | var output *os.File
100 | if s.Config.Debug {
101 | output = os.Stderr
102 | } else {
103 | output = nil
104 | }
105 |
106 | s.Opts = []selenium.ServiceOption{
107 | selenium.GeckoDriver(geckoDriverPath), // Specify the path to GeckoDriver in order to use Firefox.
108 | selenium.ChromeDriver(chromeDriverPath), // Specify the path to ChromeDriver in order to use Chrome.
109 | selenium.Output(output), // Output debug information to stderr.
110 | }
111 | if s.Config.Headless {
112 | s.Opts = append(s.Opts, selenium.StartFrameBuffer())
113 | }
114 |
115 | selenium.SetDebug(s.Config.Debug)
116 | s.Instance, err = selenium.NewSeleniumService(seleniumPath, int(s.Config.Port), s.Opts...)
117 | if err != nil {
118 | log.Fatal(err) // Fatal error, exit if webdriver can't be initialize.
119 | }
120 |
121 | if s.SigTermRoutineExit == nil {
122 | s.SigTermCleaning()
123 | }
124 | }
125 |
126 | // InitFirefoxWebDriver init and launch web driver with Firefox
127 | func (s *Selenium) InitFirefoxWebDriver() {
128 | var err error
129 | caps := selenium.Capabilities{"browserName": "firefox"}
130 | s.WebDriver, err = selenium.NewRemote(caps, fmt.Sprintf("http://localhost:%d/wd/hub", s.Config.Port))
131 | if err != nil {
132 | log.Error(err)
133 | }
134 | }
135 |
136 | // InitChromeWebDriver init and launch web driver with Chrome
137 | func (s *Selenium) InitChromeWebDriver() {
138 | var err error
139 | caps := selenium.Capabilities{"browserName": "chrome"}
140 | chromeCaps := chrome.Capabilities{
141 | Path: filepath.FromSlash(chromePath),
142 | Args: []string{
143 | "--incognito",
144 | "--disable-extensions",
145 | "--disable-infobars",
146 | "--disable-dev-shm-usage",
147 | "--no-sandbox",
148 | "--window-size=360,740",
149 | },
150 | MobileEmulation: &chrome.MobileEmulation{
151 | DeviceMetrics: &chrome.DeviceMetrics{
152 | Width: 360,
153 | Height: 740,
154 | PixelRatio: 2.05,
155 | },
156 | UserAgent: "Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) " +
157 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.137 Mobile Safari/537.36",
158 | },
159 | }
160 | caps.AddChrome(chromeCaps)
161 | if s.Proxy.Enabled {
162 | logrus.Debug("Proxy activated.")
163 | if s.Proxy.WithAuth {
164 | s.Proxy.LaunchLocalForwarder()
165 | caps.AddProxy(selenium.Proxy{
166 | Type: selenium.Manual,
167 | HTTP: "127.0.0.1:8880",
168 | FTP: "127.0.0.1:8880",
169 | SSL: "127.0.0.1:8880",
170 | NoProxy: nil,
171 | })
172 | } else {
173 | caps.AddProxy(selenium.Proxy{
174 | Type: selenium.Manual,
175 | HTTP: fmt.Sprintf("%s:%d", s.Proxy.RemoteIP, s.Proxy.RemotePort),
176 | FTP: fmt.Sprintf("%s:%d", s.Proxy.RemoteIP, s.Proxy.RemotePort),
177 | SSL: fmt.Sprintf("%s:%d", s.Proxy.RemoteIP, s.Proxy.RemotePort),
178 | NoProxy: nil,
179 | })
180 | }
181 | }
182 |
183 | s.WebDriver, err = selenium.NewRemote(caps, fmt.Sprintf("http://localhost:%d/wd/hub", s.Config.Port))
184 | if err != nil {
185 | log.Error(err)
186 | }
187 | }
188 |
189 | // CloseSelenium close webdriver and selenium instances
190 | func (s *Selenium) CloseSelenium() {
191 | if s.WebDriver != nil {
192 | s.WebDriver.Close()
193 | s.WebDriver.Quit()
194 | s.WebDriver = nil
195 | logrus.Debug("Closed webdriver")
196 | }
197 | if s.Instance != nil {
198 | s.Instance.Stop()
199 | s.Instance = nil
200 | logrus.Debug("Closed selenium instance")
201 | }
202 | }
203 |
204 | // SigTermCleaning launch a gouroutine to handle SigTerm signal and trigger Selenium and Webdriver closing if it raised
205 | func (s *Selenium) SigTermCleaning() {
206 | sig := make(chan os.Signal, 1)
207 | s.SigTermRoutineExit = make(chan bool)
208 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
209 | go func() {
210 | for {
211 | select {
212 | case <-sig:
213 | s.CleanUp()
214 | os.Exit(1)
215 | case <-s.SigTermRoutineExit:
216 | s.SigTermRoutineExit = nil
217 | return
218 | default:
219 | break
220 | }
221 | time.Sleep(10 * time.Millisecond)
222 | }
223 | }()
224 | }
225 |
226 | // CleanUp clean app ressources including Selenium stuff and proxy-login-automator instance (if exist)
227 | func (s *Selenium) CleanUp() {
228 | s.CloseSelenium()
229 | s.Proxy.StopForwarderProxy()
230 | process.DeletePidFile()
231 | logrus.Info("IGopher's ressources successfully cleared!")
232 | }
233 |
234 | /* Browser methods */
235 |
236 | // IsElementPresent check if an element is present on the current webpage
237 | func (s *Selenium) IsElementPresent(by, value string) bool {
238 | _, err := s.WebDriver.FindElement(by, value)
239 | if err != nil {
240 | log.Debugf("Element not found by %s: %v", by, err)
241 | return false
242 | }
243 | return true
244 | }
245 |
246 | // GetElement wait for element and then return when it's available
247 | func (s *Selenium) GetElement(elementTag, locator string) (selenium.WebElement, error) {
248 | locator = strings.ToUpper(locator)
249 | if locator == locatorID && s.IsElementPresent(selenium.ByID, elementTag) {
250 | return s.WebDriver.FindElement(selenium.ByID, elementTag)
251 | } else if locator == locatorName && s.IsElementPresent(selenium.ByName, elementTag) {
252 | return s.WebDriver.FindElement(selenium.ByName, elementTag)
253 | } else if locator == locatorXPath && s.IsElementPresent(selenium.ByXPATH, elementTag) {
254 | return s.WebDriver.FindElement(selenium.ByXPATH, elementTag)
255 | } else if locator == locatorCSS && s.IsElementPresent(selenium.ByCSSSelector, elementTag) {
256 | return s.WebDriver.FindElement(selenium.ByCSSSelector, elementTag)
257 | } else {
258 | log.Debugf("Incorrect locator '%s'", locator)
259 | return nil, errors.New("Incorrect locator")
260 | }
261 | }
262 |
263 | // GetElements wait for elements and then return when they're available
264 | func (s *Selenium) GetElements(elementTag, locator string) ([]selenium.WebElement, error) {
265 | locator = strings.ToUpper(locator)
266 | if locator == locatorID && s.IsElementPresent(selenium.ByID, elementTag) {
267 | return s.WebDriver.FindElements(selenium.ByID, elementTag)
268 | } else if locator == locatorName && s.IsElementPresent(selenium.ByName, elementTag) {
269 | return s.WebDriver.FindElements(selenium.ByName, elementTag)
270 | } else if locator == locatorXPath && s.IsElementPresent(selenium.ByXPATH, elementTag) {
271 | return s.WebDriver.FindElements(selenium.ByXPATH, elementTag)
272 | } else if locator == locatorCSS && s.IsElementPresent(selenium.ByCSSSelector, elementTag) {
273 | return s.WebDriver.FindElements(selenium.ByCSSSelector, elementTag)
274 | } else {
275 | log.Debugf("Incorrect locator '%s'", locator)
276 | return nil, errors.New("Incorrect locator")
277 | }
278 | }
279 |
280 | // WaitForElement search and wait until searched element appears.
281 | // Delay argument is in seconds.
282 | func (s *Selenium) WaitForElement(elementTag, locator string, delay int) (bool, error) {
283 | locator = strings.ToUpper(locator)
284 | s.WebDriver.SetImplicitWaitTimeout(0)
285 | defer s.WebDriver.SetImplicitWaitTimeout(30)
286 |
287 | timeout := time.After(time.Duration(delay) * time.Second)
288 | tick := time.NewTicker(500 * time.Millisecond)
289 | for {
290 | select {
291 | case <-timeout:
292 | return false, errors.New("Timed out : element not found")
293 | case <-tick.C:
294 | if (locator == locatorID && s.IsElementPresent(selenium.ByID, elementTag)) ||
295 | (locator == locatorName && s.IsElementPresent(selenium.ByName, elementTag)) ||
296 | (locator == locatorXPath && s.IsElementPresent(selenium.ByXPATH, elementTag)) ||
297 | (locator == locatorCSS && s.IsElementPresent(selenium.ByCSSSelector, elementTag)) {
298 | return true, nil
299 | }
300 | }
301 | time.Sleep(10 * time.Millisecond)
302 | }
303 | }
304 |
305 | // Fatal closes all selenium stuff and call logrus fatal with error printing
306 | func (s *Selenium) Fatal(msg string, err error) {
307 | s.CleanUp()
308 | logrus.Fatal(msg, err)
309 | }
310 |
--------------------------------------------------------------------------------
/internal/gui/comm/comm.go:
--------------------------------------------------------------------------------
1 | package comm
2 |
3 | import (
4 | "github.com/asticode/go-astilectron"
5 | "github.com/hbollon/igopher/internal/gui/datatypes"
6 | )
7 |
8 | var (
9 | Window *astilectron.Window
10 | )
11 |
12 | // IsElectronRunning checks if electron is running
13 | func IsElectronRunning() bool {
14 | return Window != nil
15 | }
16 |
17 | // SendMessageToElectron will send a message to Electron Gui and execute a callback
18 | // Callback function is optional
19 | func SendMessageToElectron(msg datatypes.MessageOut, callbacks ...astilectron.CallbackMessage) {
20 | if IsElectronRunning() {
21 | Window.SendMessage(msg, callbacks...)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/internal/gui/datatypes/types.go:
--------------------------------------------------------------------------------
1 | package datatypes
2 |
3 | import "encoding/json"
4 |
5 | type MsgState string
6 |
7 | const (
8 | SUCCESS MsgState = "Success"
9 | ERROR MsgState = "Error"
10 | INFO MsgState = "Info"
11 | )
12 |
13 | // MessageOut represents a message for electron (going out)
14 | type MessageOut struct {
15 | Status MsgState `json:"status"`
16 | Msg string `json:"msg"`
17 | Payload interface{} `json:"payload,omitempty"`
18 | }
19 |
20 | // MessageIn represents a message from electron (going in)
21 | type MessageIn struct {
22 | Msg string `json:"msg"`
23 | Payload json.RawMessage `json:"payload,omitempty"`
24 | }
25 |
26 | func (m *MessageIn) Callback(callback func(m *MessageIn) MessageOut) MessageOut {
27 | return callback(m)
28 | }
29 |
--------------------------------------------------------------------------------
/internal/gui/messages.go:
--------------------------------------------------------------------------------
1 | package gui
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 |
8 | "github.com/asticode/go-astilectron"
9 | "github.com/go-playground/validator/v10"
10 | "github.com/hbollon/igopher/internal/automation"
11 | bot "github.com/hbollon/igopher/internal/automation"
12 | conf "github.com/hbollon/igopher/internal/config"
13 | confdata "github.com/hbollon/igopher/internal/config/types"
14 | "github.com/hbollon/igopher/internal/gui/comm"
15 | "github.com/hbollon/igopher/internal/gui/datatypes"
16 | "github.com/hbollon/igopher/internal/logger"
17 | "github.com/hbollon/igopher/internal/proxy"
18 | "github.com/sirupsen/logrus"
19 | )
20 |
21 | var (
22 | config confdata.BotConfigYaml
23 | validate = validator.New()
24 | ctx context.Context
25 | cancel context.CancelFunc
26 | )
27 |
28 | // CallbackMap is a map of callback functions for each message
29 | var CallbackMap = map[string]func(m *datatypes.MessageIn) datatypes.MessageOut{
30 | "resetGlobalDefaultSettings": resetGlobalSettingsCallback,
31 | "clearAllData": clearDataCallback,
32 | "igCredentialsForm": credentialsFormCallback,
33 | "quotasForm": quotasFormCallback,
34 | "schedulerForm": schedulerCallback,
35 | "blacklistForm": blacklistFormCallback,
36 | "dmSettingsForm": dmBotFormCallback,
37 | "dmUserScrappingSettingsForm": dmScrapperFormCallback,
38 | "proxyForm": proxyFormCallback,
39 | "launchDmBot": launchDmBotCallback,
40 | "stopDmBot": stopDmBotCallback,
41 | "hotReloadBot": hotReloadCallback,
42 | "getLogs": getLogsCallback,
43 | "getConfig": getConfigCallback,
44 | }
45 |
46 | // HandleMessages is handling function for incoming messages
47 | func HandleMessages(w *astilectron.Window) {
48 | w.OnMessage(func(m *astilectron.EventMessage) interface{} {
49 | // Unmarshal
50 | var i datatypes.MessageIn
51 | var err error
52 | if err = m.Unmarshal(&i); err != nil {
53 | logrus.Errorf("Unmarshaling message %+v failed: %v", *m, err)
54 | return datatypes.MessageOut{Status: "Error during message reception"}
55 | }
56 |
57 | // Process message
58 | config = conf.ImportConfig()
59 | if callback, ok := CallbackMap[i.Msg]; ok {
60 | return i.Callback(callback)
61 | }
62 | logrus.Errorf("Unexpected message received: \"%s\"", i.Msg)
63 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Unknown error: Invalid message received"}
64 | })
65 | comm.Window = w
66 | }
67 |
68 | /* Callback functiosn to handle electron messages */
69 |
70 | func resetGlobalSettingsCallback(_ *datatypes.MessageIn) datatypes.MessageOut {
71 | config = conf.ResetBotConfig()
72 | conf.ExportConfig(config)
73 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Global configuration was successfully reset!"}
74 | }
75 |
76 | func clearDataCallback(_ *datatypes.MessageIn) datatypes.MessageOut {
77 | if err := conf.ClearData(); err != nil {
78 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: fmt.Sprintf("IGopher data clearing failed! Error: %v", err)}
79 | }
80 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "IGopher data successfully cleared!"}
81 | }
82 |
83 | func credentialsFormCallback(m *datatypes.MessageIn) datatypes.MessageOut {
84 | var err error
85 | var credentialsConfig confdata.AccountYaml
86 | // Unmarshal payload
87 | if err = json.Unmarshal([]byte(m.Payload), &credentialsConfig); err != nil {
88 | logrus.Errorf("Failed to unmarshal message payload: %v", err)
89 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Failed to unmarshal message payload."}
90 | }
91 |
92 | err = validate.Struct(credentialsConfig)
93 | if err != nil {
94 | logrus.Warning("Validation issue on credentials form, abort.")
95 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Validation issue on credentials form, please check given informations."}
96 | }
97 |
98 | config.Account = credentialsConfig
99 | conf.ExportConfig(config)
100 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Credentials settings successfully updated!"}
101 | }
102 |
103 | func quotasFormCallback(m *datatypes.MessageIn) datatypes.MessageOut {
104 | var err error
105 | var quotasConfig confdata.QuotasYaml
106 | // Unmarshal payload
107 | if err = json.Unmarshal([]byte(m.Payload), "asConfig); err != nil {
108 | logrus.Errorf("Failed to unmarshal message payload: %v", err)
109 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Failed to unmarshal message payload."}
110 | }
111 |
112 | err = validate.Struct(quotasConfig)
113 | if err != nil {
114 | logrus.Warning("Validation issue on quotas form, abort.")
115 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Validation issue on quotas form, please check given informations."}
116 | }
117 |
118 | config.Quotas = quotasConfig
119 | conf.ExportConfig(config)
120 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Quotas settings successfully updated!"}
121 | }
122 |
123 | func schedulerCallback(m *datatypes.MessageIn) datatypes.MessageOut {
124 | var err error
125 | var schedulerConfig confdata.ScheduleYaml
126 | // Unmarshal payload
127 | if err = json.Unmarshal([]byte(m.Payload), &schedulerConfig); err != nil {
128 | logrus.Errorf("Failed to unmarshal message payload: %v", err)
129 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Failed to unmarshal message payload."}
130 | }
131 |
132 | err = validate.Struct(schedulerConfig)
133 | if err != nil {
134 | logrus.Warning("Validation issue on scheduler form, abort.")
135 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Validation issue on scheduler form, please check given informations."}
136 | }
137 |
138 | config.Schedule = schedulerConfig
139 | conf.ExportConfig(config)
140 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Scheduler settings successfully updated!"}
141 | }
142 |
143 | func blacklistFormCallback(m *datatypes.MessageIn) datatypes.MessageOut {
144 | var err error
145 | var blacklistConfig confdata.BlacklistYaml
146 | // Unmarshal payload
147 | if err = json.Unmarshal([]byte(m.Payload), &blacklistConfig); err != nil {
148 | logrus.Errorf("Failed to unmarshal message payload: %v", err)
149 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Failed to unmarshal message payload."}
150 | }
151 |
152 | err = validate.Struct(blacklistConfig)
153 | if err != nil {
154 | logrus.Warning("Validation issue on blacklist form, abort.")
155 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Validation issue on blacklist form, please check given informations."}
156 | }
157 |
158 | config.Blacklist = blacklistConfig
159 | conf.ExportConfig(config)
160 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Blacklist settings successfully updated!"}
161 | }
162 |
163 | func dmBotFormCallback(m *datatypes.MessageIn) datatypes.MessageOut {
164 | var err error
165 | var dmConfig struct {
166 | DmTemplates confdata.SplitStringSlice `json:"dmTemplates" validate:"required"`
167 | GreetingTemplate string `json:"greetingTemplate"`
168 | GreetingActivated bool `json:"greetingActivation,string"`
169 | Activated bool `json:"dmActivation,string"`
170 | }
171 | // Unmarshal payload
172 | if err = json.Unmarshal([]byte(m.Payload), &dmConfig); err != nil {
173 | logrus.Errorf("Failed to unmarshal message payload: %v", err)
174 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Failed to unmarshal message payload."}
175 | }
176 |
177 | err = validate.Struct(dmConfig)
178 | if err != nil {
179 | logrus.Warning("Validation issue on dm tool form, abort.")
180 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Validation issue on dm tool form, please check given informations."}
181 | }
182 |
183 | config.AutoDm.DmTemplates = dmConfig.DmTemplates
184 | config.AutoDm.Greeting.Template = dmConfig.GreetingTemplate
185 | config.AutoDm.Greeting.Activated = dmConfig.GreetingActivated
186 | config.AutoDm.Activated = dmConfig.Activated
187 | conf.ExportConfig(config)
188 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Dm bot settings successfully updated!"}
189 | }
190 |
191 | func dmScrapperFormCallback(m *datatypes.MessageIn) datatypes.MessageOut {
192 | var err error
193 | var scrapperConfig confdata.ScrapperYaml
194 | // Unmarshal payload
195 | if err = json.Unmarshal([]byte(m.Payload), &scrapperConfig); err != nil {
196 | logrus.Errorf("Failed to unmarshal message payload: %v", err)
197 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Failed to unmarshal message payload."}
198 | }
199 |
200 | err = validate.Struct(scrapperConfig)
201 | if err != nil {
202 | logrus.Warning("Validation issue on scrapper form, abort.")
203 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Validation issue on scrapper form, please check given informations."}
204 | }
205 |
206 | config.SrcUsers = scrapperConfig
207 | conf.ExportConfig(config)
208 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Scrapper settings successfully updated!"}
209 | }
210 |
211 | func proxyFormCallback(m *datatypes.MessageIn) datatypes.MessageOut {
212 | var err error
213 | var proxyConfig proxy.Proxy
214 | // Unmarshal payload
215 | if err = json.Unmarshal([]byte(m.Payload), &proxyConfig); err != nil {
216 | logrus.Errorf("Failed to unmarshal message payload: %v", err)
217 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Failed to unmarshal message payload."}
218 | }
219 |
220 | err = validate.Struct(proxyConfig)
221 | if err != nil {
222 | logrus.Warning("Validation issue on proxy form, abort.")
223 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Validation issue on proxy form, please check given informations."}
224 | }
225 |
226 | config.Selenium.Proxy = proxyConfig
227 | conf.ExportConfig(config)
228 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Proxy settings successfully updated!"}
229 | }
230 |
231 | func launchDmBotCallback(_ *datatypes.MessageIn) datatypes.MessageOut {
232 | var err error
233 | if err = conf.CheckConfigValidity(); err == nil {
234 | ctx, cancel = context.WithCancel(context.Background())
235 | go bot.LaunchBot(ctx)
236 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Dm bot successfully launched!"}
237 | }
238 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: err.Error()}
239 | }
240 |
241 | func stopDmBotCallback(_ *datatypes.MessageIn) datatypes.MessageOut {
242 | if bot.ExitedCh != nil {
243 | cancel()
244 | res := <-bot.ExitedCh
245 | if res {
246 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Dm bot successfully stopped!"}
247 | }
248 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Error during bot stopping! Please restart IGopher"}
249 | }
250 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Bot is in the initialization phase, please wait before trying to stop it."}
251 | }
252 |
253 | func hotReloadCallback(_ *datatypes.MessageIn) datatypes.MessageOut {
254 | if automation.BotStruct.Running {
255 | if bot.HotReloadCh != nil {
256 | bot.HotReloadCh <- true
257 | res := <-bot.HotReloadCh
258 | if res {
259 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: "Bot hot reload successfully!"}
260 | }
261 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Error during bot hot reload! Please restart the bot"}
262 | }
263 | return datatypes.MessageOut{Status: datatypes.ERROR,
264 | Msg: "Bot is in the initialization phase, please wait before trying to hot reload it."}
265 | }
266 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: "Bot isn't running yet."}
267 | }
268 |
269 | func getLogsCallback(_ *datatypes.MessageIn) datatypes.MessageOut {
270 | logs, err := logger.ParseLogsToString()
271 | if err != nil {
272 | logrus.Errorf("Can't parse logs: %v", err)
273 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: fmt.Sprintf("Can't parse logs: %v", err)}
274 | }
275 | logrus.Debug("Logs fetched successfully!")
276 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: logs}
277 | }
278 |
279 | func getConfigCallback(_ *datatypes.MessageIn) datatypes.MessageOut {
280 | config, err := json.Marshal(config)
281 | if err != nil {
282 | logrus.Errorf("Can't parse config structure to Json: %v", err)
283 | return datatypes.MessageOut{Status: datatypes.ERROR, Msg: fmt.Sprintf("Can't parse config structure to Json: %v", err)}
284 | }
285 | logrus.Debug("Configuration structure successfully parsed!")
286 | return datatypes.MessageOut{Status: datatypes.SUCCESS, Msg: string(config)}
287 | }
288 |
--------------------------------------------------------------------------------
/internal/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "bufio"
5 | "os"
6 | "runtime"
7 |
8 | logRuntime "github.com/banzaicloud/logrus-runtime-formatter"
9 | "github.com/hbollon/igopher/internal/config/flags"
10 | "github.com/rifflock/lfshook"
11 | "github.com/shiena/ansicolor"
12 | "github.com/sirupsen/logrus"
13 | log "github.com/sirupsen/logrus"
14 | )
15 |
16 | const logFilePath = "./logs/logs.log"
17 |
18 | func InitLogger() {
19 | SetLoggerOutput()
20 | level, err := log.ParseLevel(*flags.Flags.LogLevelFlag)
21 | if err == nil {
22 | log.SetLevel(level)
23 | } else {
24 | log.SetLevel(log.InfoLevel)
25 | log.Warnf("Invalid log level '%s', use default one.", *flags.Flags.LogLevelFlag)
26 | }
27 | }
28 |
29 | // SetLoggerOutput sets the output of the logger
30 | func SetLoggerOutput() {
31 | // Initialize logs folder
32 | if _, err := os.Stat("./logs/"); os.IsNotExist(err) {
33 | os.Mkdir("./logs/", os.ModePerm)
34 | }
35 |
36 | // Add formatter to logrus in order to display line and function with messages on Stdout
37 | formatter := logRuntime.Formatter{ChildFormatter: &log.TextFormatter{
38 | FullTimestamp: false,
39 | ForceColors: true,
40 | }}
41 | formatter.Line = true
42 | log.SetFormatter(&formatter)
43 |
44 | if runtime.GOOS == "windows" {
45 | log.SetOutput(ansicolor.NewAnsiColorWriter(os.Stdout))
46 | } else {
47 | log.SetOutput(os.Stdout)
48 | }
49 |
50 | // Add hook to logrus to also redirect logs to files with custom formatter
51 | log.AddHook(lfshook.NewHook(
52 | lfshook.PathMap{
53 | logrus.InfoLevel: logFilePath,
54 | logrus.WarnLevel: logFilePath,
55 | logrus.ErrorLevel: logFilePath,
56 | logrus.FatalLevel: logFilePath,
57 | },
58 | &logrus.JSONFormatter{},
59 | ))
60 | }
61 |
62 | // Read and parse log file to json array string
63 | func ParseLogsToString() (string, error) {
64 | // Open log file
65 | file, err := os.Open(logFilePath)
66 | if err != nil {
67 | return "", err
68 | }
69 | defer file.Close()
70 |
71 | // Parse logs to string array
72 | var logs []string
73 | scanner := bufio.NewScanner(file)
74 | scanner.Split(bufio.ScanLines)
75 | for scanner.Scan() {
76 | logs = append(logs, scanner.Text())
77 | }
78 |
79 | // Build json array string with logs from newer to older
80 | out := `[`
81 | for i := len(logs) - 1; i >= 0; i-- {
82 | out += logs[i]
83 | if i == 0 {
84 | break
85 | }
86 | out += `,`
87 | }
88 | out += `]`
89 |
90 | return out, nil
91 | }
92 |
--------------------------------------------------------------------------------
/internal/modules/blacklist/blacklist.go:
--------------------------------------------------------------------------------
1 | package blacklist
2 |
3 | import (
4 | "encoding/csv"
5 | "os"
6 |
7 | "github.com/sirupsen/logrus"
8 | "github.com/tebeka/selenium"
9 | )
10 |
11 | const (
12 | fileBlacklistPath = "data/blacklist.csv"
13 | )
14 |
15 | // Manager data
16 | type Manager struct {
17 | // BlacklistedUsers: list of all blacklisted usernames
18 | BlacklistedUsers [][]string
19 | // Activated: quota manager activation boolean
20 | Activated bool `yaml:"activated"`
21 | }
22 |
23 | // InitializeBlacklist check existence of the blacklist csv file and initialize it if it doesn't exist.
24 | func (bm *Manager) InitializeBlacklist() error {
25 | var err error
26 | // Check if blacklist csv exist
27 | _, err = os.Stat(fileBlacklistPath)
28 | if err != nil {
29 | if os.IsNotExist(err) {
30 | // Create data folder if not exist
31 | if _, err = os.Stat("data/"); os.IsNotExist(err) {
32 | os.Mkdir("data/", os.ModePerm)
33 | }
34 | // Create and open csv blacklist
35 | var f *os.File
36 | f, err = os.OpenFile(fileBlacklistPath, os.O_RDWR|os.O_CREATE, 0755)
37 | if err != nil {
38 | return err
39 | }
40 | defer f.Close()
41 | // Write csv header
42 | writer := csv.NewWriter(f)
43 | err = writer.Write([]string{"Username"})
44 | defer writer.Flush()
45 | if err != nil {
46 | return err
47 | }
48 | } else {
49 | return err
50 | }
51 | } else {
52 | // Open existing blacklist and recover blacklisted usernames
53 | f, err := os.OpenFile(fileBlacklistPath, os.O_RDONLY, 0644)
54 | if err != nil {
55 | return err
56 | }
57 | defer f.Close()
58 |
59 | reader := csv.NewReader(f)
60 | bm.BlacklistedUsers, err = reader.ReadAll()
61 | if err != nil {
62 | return err
63 | }
64 | }
65 |
66 | return nil
67 | }
68 |
69 | // AddUser add argument username to the blacklist
70 | func (bm *Manager) AddUser(user string) {
71 | bm.BlacklistedUsers = append(bm.BlacklistedUsers, []string{user})
72 | f, err := os.OpenFile(fileBlacklistPath, os.O_WRONLY|os.O_APPEND, 0644)
73 | if err != nil {
74 | logrus.Errorf("Failed to blacklist current user: %v", err)
75 | }
76 | defer f.Close()
77 |
78 | writer := csv.NewWriter(f)
79 | err = writer.Write([]string{user})
80 | defer writer.Flush()
81 | if err != nil {
82 | logrus.Errorf("Failed to blacklist current user: %v", err)
83 | }
84 | }
85 |
86 | // IsBlacklisted check if the given user is already blacklisted
87 | func (bm *Manager) IsBlacklisted(user string) bool {
88 | for _, username := range bm.BlacklistedUsers {
89 | if username[0] == user {
90 | return true
91 | }
92 | }
93 | return false
94 | }
95 |
96 | // FilterScrappedUsers remove blacklisted users from WebElement slice and return it
97 | func (bm *Manager) FilterScrappedUsers(users []selenium.WebElement) []selenium.WebElement {
98 | var filteredUsers []selenium.WebElement
99 | for _, user := range users {
100 | username, err := user.Text()
101 | if !bm.IsBlacklisted(username) && err == nil {
102 | filteredUsers = append(filteredUsers, user)
103 | }
104 | }
105 | return filteredUsers
106 | }
107 |
--------------------------------------------------------------------------------
/internal/modules/quotas/quotas.go:
--------------------------------------------------------------------------------
1 | package quotas
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/sirupsen/logrus"
7 | )
8 |
9 | // QuotaManager data
10 | type QuotaManager struct {
11 | // HourTimestamp: hourly timestamp used to handle hour limitations
12 | HourTimestamp time.Time
13 | // DayTimestamp: daily timestamp used to handle day limitations
14 | DayTimestamp time.Time
15 | // DmSent: quantity of dm sent in the last hour
16 | DmSent int
17 | // DmSentDay: quantity of dm sent in the last day
18 | DmSentDay int
19 | // MaxDmHour: maximum dm quantity per hour
20 | MaxDmHour int `yaml:"dm_per_hour" validate:"numeric"`
21 | // MaxDmDay: maximum dm quantity per day
22 | MaxDmDay int `yaml:"dm_per_day" validate:"numeric"`
23 | // Activated: quota manager activation boolean
24 | Activated bool `yaml:"activated"`
25 | }
26 |
27 | // InitializeQuotaManager initialize Quota manager with user settings
28 | func (qm *QuotaManager) InitializeQuotaManager() {
29 | qm.HourTimestamp = time.Now()
30 | qm.DayTimestamp = time.Now()
31 | }
32 |
33 | // ResetDailyQuotas reset daily dm counter and update timestamp
34 | func (qm *QuotaManager) ResetDailyQuotas() {
35 | qm.DmSentDay = 0
36 | qm.DayTimestamp = time.Now()
37 | }
38 |
39 | // ResetHourlyQuotas reset hourly dm counter and update timestamp
40 | func (qm *QuotaManager) ResetHourlyQuotas() {
41 | qm.DmSent = 0
42 | qm.HourTimestamp = time.Now()
43 | }
44 |
45 | // AddDm report to the manager a message sending. It increment dm counter and check if quotas are still valid.
46 | func (qm *QuotaManager) AddDm() {
47 | qm.DmSent++
48 | qm.DmSentDay++
49 | qm.CheckQuotas()
50 | }
51 |
52 | // CheckQuotas check if quotas have not been exceeded and pauses the program otherwise.
53 | func (qm *QuotaManager) CheckQuotas() {
54 | // Hourly quota checking
55 | if qm.DmSent >= qm.MaxDmHour && qm.Activated {
56 | if time.Since(qm.HourTimestamp).Seconds() < 3600 {
57 | sleepDur := 3600 - time.Since(qm.HourTimestamp).Seconds()
58 | logrus.Infof("Hourly quota reached, sleeping %f seconds...", sleepDur)
59 | time.Sleep(time.Duration(sleepDur) * time.Second)
60 | } else {
61 | qm.ResetHourlyQuotas()
62 | logrus.Info("Hourly quotas resetted.")
63 | }
64 | }
65 | // Daily quota checking
66 | if qm.DmSentDay >= qm.MaxDmDay && qm.Activated {
67 | if time.Since(qm.DayTimestamp).Seconds() < 86400 {
68 | sleepDur := 86400 - time.Since(qm.DayTimestamp).Seconds()
69 | logrus.Infof("Daily quota reached, sleeping %f seconds...", sleepDur)
70 | time.Sleep(time.Duration(sleepDur) * time.Second)
71 | } else {
72 | qm.ResetDailyQuotas()
73 | logrus.Info("Daily quotas resetted.")
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/internal/modules/scheduler/scheduler.go:
--------------------------------------------------------------------------------
1 | package scheduler
2 |
3 | import (
4 | "errors"
5 | "strings"
6 | "time"
7 |
8 | "github.com/sirupsen/logrus"
9 | )
10 |
11 | // Manager data
12 | type Manager struct {
13 | // BeginAt: Begin time setting
14 | BeginAt string `yaml:"begin_at" validate:"contains=:"`
15 | // EndAt: End time setting
16 | EndAt string `yaml:"end_at" validate:"contains=:"`
17 | // BeginAtTimestamp: begin timestamp
18 | BeginAtTimestamp time.Time
19 | // EndAtTimestamp: end timestamp
20 | EndAtTimestamp time.Time
21 | // Activated: quota manager activation boolean
22 | Activated bool `yaml:"activated"`
23 | }
24 |
25 | // InitializeScheduler convert string time from config to time.Time instances
26 | func (s *Manager) InitializeScheduler() error {
27 | ttBegin, err := time.Parse("15:04", strings.TrimSpace(s.BeginAt))
28 | if err != nil {
29 | return err
30 | }
31 | s.BeginAtTimestamp = ttBegin
32 | ttEnd, err := time.Parse("15:04", strings.TrimSpace(s.EndAt))
33 | if err != nil {
34 | return err
35 | }
36 | s.EndAtTimestamp = ttEnd
37 | return nil
38 | }
39 |
40 | // CheckTime check scheduler and pause the bot if it's not working time
41 | func (s *Manager) CheckTime() error {
42 | if !s.Activated {
43 | return nil
44 | }
45 | res, err := s.isWorkingTime()
46 | if err == nil {
47 | if res {
48 | return nil
49 | }
50 | logrus.Info("Reached end of service. Sleeping...")
51 | for {
52 | if res, _ = s.isWorkingTime(); res {
53 | break
54 | }
55 | // if engine.BotStruct.ExitCh != nil {
56 | // select {
57 | // case <-engine.BotStruct.HotReloadCallback:
58 | // if err = engine.BotStruct.HotReload(); err != nil {
59 | // logrus.Errorf("Bot hot reload failed: %v", err)
60 | // engine.BotStruct.HotReloadCallback <- false
61 | // } else {
62 | // logrus.Info("Bot hot reload successfully.")
63 | // engine.BotStruct.HotReloadCallback <- true
64 | // }
65 | // break
66 | // case <-engine.BotStruct.ExitCh:
67 | // logrus.Info("Bot process successfully stopped.")
68 | // return bot.ErrStopBot
69 | // default:
70 | // break
71 | // }
72 | // }
73 | time.Sleep(10 * time.Second)
74 | }
75 | logrus.Info("Back to work!")
76 | }
77 | return nil
78 | }
79 |
80 | // Check if current time is between scheduler working interval
81 | func (s *Manager) isWorkingTime() (bool, error) {
82 | if s.BeginAtTimestamp.Equal(s.EndAtTimestamp) {
83 | return false, errors.New("Bad scheduler configuration")
84 | }
85 | currentTime := time.Date(0, time.January, 1, time.Now().Hour(), time.Now().Minute(), 0, 0, time.Local)
86 | if s.BeginAtTimestamp.Before(s.EndAtTimestamp) {
87 | return !currentTime.Before(s.BeginAtTimestamp) && !currentTime.After(s.EndAtTimestamp), nil
88 | }
89 | return !s.BeginAtTimestamp.After(currentTime) || !s.EndAtTimestamp.Before(currentTime), nil
90 | }
91 |
--------------------------------------------------------------------------------
/internal/process/process.go:
--------------------------------------------------------------------------------
1 | package process
2 |
3 | import (
4 | "bufio"
5 | "os"
6 | "strconv"
7 |
8 | "github.com/mitchellh/go-ps"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | // In this package, we use go-ps FindProcess method for process finding instead os one to be able to detect
13 | // if a process is running even on unix systems (indeed, os's FindProcess always return a process on unix even if it's not running)
14 |
15 | var pidFilePath string
16 |
17 | // Init take into parameter pid file path and update associated process package variable
18 | func Init(path string) {
19 | pidFilePath = path
20 | }
21 |
22 | // CheckIfAlreadyRunning check for pid file (location is given by pidFilePath parameter) file existence.
23 | // If exist, it'll get saved pid and check if the process is still running.
24 | // Returns a boolean notifying if the process is running and the process in question if it exists
25 | func CheckIfAlreadyRunning() (bool, ps.Process) {
26 | if _, err := os.Stat(pidFilePath); err == nil {
27 | var file *os.File
28 | file, err = os.Open(pidFilePath)
29 | if err != nil {
30 | logrus.Error("Failed to open existing pid file located at './data/pid.txt'.")
31 | logrus.Error(err)
32 | return true, nil
33 | }
34 | defer file.Close()
35 |
36 | scanner := bufio.NewScanner(file)
37 | scanner.Split(bufio.ScanWords)
38 | if res := scanner.Scan(); !res {
39 | logrus.Warn("Pid file exist but without content, IGopher may be already running.")
40 | logrus.Info("Delete corrupt pid file and continue.")
41 | if err = os.Remove(pidFilePath); err != nil {
42 | logrus.Error("Failed to delete corrupt pid file!")
43 | }
44 | return false, nil
45 | }
46 | pidStr := scanner.Text()
47 |
48 | pid, _ := strconv.Atoi(pidStr)
49 | var process ps.Process
50 | process, err = ps.FindProcess(pid)
51 | if process == nil && err == nil {
52 | logrus.Warnf("Failed to find process: %s\n. The pid must be outdated.", err)
53 | logrus.Info("Delete outdated pid file and continue.")
54 | if err = os.Remove(pidFilePath); err != nil {
55 | logrus.Error("Failed to delete corrupt pid file!")
56 | }
57 | return false, nil
58 | }
59 |
60 | return true, process
61 | } else if os.IsNotExist(err) {
62 | return false, nil
63 | } else {
64 | logrus.Fatalf(
65 | "Unknown issue during pid file checking: try to manually check if './data/pid.txt' exist and delete it. Detailed error: %v\n",
66 | err,
67 | )
68 | }
69 |
70 | return false, nil
71 | }
72 |
73 | // DumpProcessPidToFile get program pid and save it to pidFilePath file
74 | func DumpProcessPidToFile() {
75 | pid := strconv.Itoa(os.Getpid())
76 | file, err := os.Create(pidFilePath)
77 | if err != nil {
78 | panic(err)
79 | }
80 | defer file.Close()
81 |
82 | _, err = file.WriteString(pid)
83 | if err != nil {
84 | logrus.Fatalf("Failed to dump IGopher pid to file! Exit program. Detailed error: %v\n", err)
85 | }
86 | }
87 |
88 | // DeletePidFile delete pid file if exists
89 | func DeletePidFile() {
90 | err := os.Remove(pidFilePath)
91 | if err != nil {
92 | logrus.Debug(err)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/internal/process/process_unix.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package process
4 |
5 | import (
6 | "fmt"
7 | "os"
8 | "syscall"
9 | )
10 |
11 | // TerminateRunningInstance check if the pid stored in the pid file is running and, if yes, terminate it.
12 | func TerminateRunningInstance() error {
13 | if res, psProcess := CheckIfAlreadyRunning(); res && psProcess != nil {
14 | process, err := os.FindProcess(psProcess.Pid())
15 | if err != nil {
16 | return err
17 | }
18 |
19 | err = process.Signal(syscall.SIGTERM)
20 | if err != nil {
21 | return err
22 | }
23 |
24 | return nil
25 | }
26 |
27 | return fmt.Errorf("Failed to recover running igopher process")
28 | }
29 |
--------------------------------------------------------------------------------
/internal/process/process_windows.go:
--------------------------------------------------------------------------------
1 | //go:build windows
2 |
3 | package process
4 |
5 | import (
6 | "fmt"
7 | "os"
8 | "syscall"
9 |
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | // TerminateRunningInstance check if the pid stored in the pid file is running and, if yes, terminate it.
14 | func TerminateRunningInstance() error {
15 | if res, psProcess := CheckIfAlreadyRunning(); res && psProcess != nil {
16 | process, err := os.FindProcess(psProcess.Pid())
17 | if err != nil {
18 | return err
19 | }
20 |
21 | dll, err := syscall.LoadDLL("kernel32.dll")
22 | if err != nil {
23 | logrus.Fatalf("LoadDLL: %v\n", err)
24 | }
25 | dllProc, err := dll.FindProc("GenerateConsoleCtrlEvent")
26 | if err != nil {
27 | logrus.Fatalf("FindProc: %v\n", err)
28 | }
29 | r, _, e := dllProc.Call(syscall.CTRL_BREAK_EVENT, uintptr(process.Pid))
30 | if r == 0 {
31 | logrus.Fatalf("GenerateConsoleCtrlEvent: %v\n", e)
32 | }
33 |
34 | return nil
35 | }
36 |
37 | return fmt.Errorf("Failed to recover running igopher process")
38 | }
39 |
--------------------------------------------------------------------------------
/internal/proxy/proxy.go:
--------------------------------------------------------------------------------
1 | package proxy
2 |
3 | import (
4 | "fmt"
5 | "os/exec"
6 | "runtime"
7 | "time"
8 |
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | // Proxy store all remote proxy configuration
13 | type Proxy struct {
14 | RemoteIP string `json:"ip" yaml:"ip" validate:"required,contains=."`
15 | RemotePort int `json:"port,string" yaml:"port" validate:"required,numeric,min=1,max=65535"`
16 | RemoteUsername string `json:"username" yaml:"username"`
17 | RemotePassword string `json:"password" yaml:"password"`
18 |
19 | WithAuth bool `json:"auth,string" yaml:"auth"`
20 | Enabled bool `json:"proxyActivation,string" yaml:"activated"`
21 |
22 | running bool
23 | stopProxyForwarderChan chan bool
24 | errorProxyForwarderChan chan error
25 | }
26 |
27 | // LaunchLocalForwarder launch an instance of proxy-login-automator (https://github.com/hbollon/proxy-login-automator) which starts
28 | // a local forwarder proxy server in order to be able to automatically inject the "Proxy-Authorization" header
29 | // to all outgoing Selenium requests and forward them to the remote proxy configured by the user.
30 | func (p *Proxy) LaunchLocalForwarder() error {
31 | var executable string
32 | if runtime.GOOS == "windows" {
33 | executable = "./lib/proxy-login-automator.exe"
34 | } else {
35 | executable = "./lib/proxy-login-automator"
36 | }
37 |
38 | options := []string{
39 | "-local_host",
40 | "127.0.0.1",
41 | "-local_port",
42 | "8880",
43 | "-remote_host",
44 | p.RemoteIP,
45 | "-remote_port",
46 | fmt.Sprintf("%d", p.RemotePort),
47 | "-usr",
48 | p.RemoteUsername,
49 | "-pwd",
50 | p.RemotePassword,
51 | }
52 |
53 | p.stopProxyForwarderChan = make(chan bool)
54 | go func() {
55 | defer close(p.stopProxyForwarderChan)
56 | cmd := exec.Command(executable, options...)
57 |
58 | // Removed atm due to its incompatibility with OS other than Linux
59 | // cmd.SysProcAttr = &syscall.SysProcAttr{
60 | // Pdeathsig: syscall.SIGKILL,
61 | // }
62 |
63 | if err := cmd.Start(); err != nil {
64 | logrus.Errorf("Failed to launch local proxy-login-automator server: %v", err)
65 | }
66 | logrus.Debug("proxy-login-automator server successfully launched ! ")
67 | p.running = true
68 |
69 | p.errorProxyForwarderChan = make(chan error)
70 | defer close(p.errorProxyForwarderChan)
71 | go func() {
72 | p.errorProxyForwarderChan <- cmd.Wait()
73 | }()
74 |
75 | for {
76 | select {
77 | case <-p.stopProxyForwarderChan:
78 | cmd.Process.Kill()
79 | <-p.errorProxyForwarderChan // ignore cmd.Wait() output
80 | logrus.Debug("Successfully stopped proxy-login-automator server.")
81 | p.running = false
82 | return
83 | case err := <-p.errorProxyForwarderChan:
84 | logrus.Error(err)
85 | p.running = false
86 | return
87 | default:
88 | break
89 | }
90 | time.Sleep(10 * time.Millisecond)
91 | }
92 | }()
93 | time.Sleep(5 * time.Second)
94 |
95 | return nil
96 | }
97 |
98 | // RestartForwarderProxy check for running instance of proxy-login-automator, stop it if exist and finally start a new one
99 | func (p *Proxy) RestartForwarderProxy() error {
100 | logrus.Debug("Restarting proxy-login-automator...")
101 | if p.running && p.stopProxyForwarderChan != nil {
102 | logrus.Debug("-> Stopping current proxy instance...")
103 | p.stopProxyForwarderChan <- true
104 | }
105 | if err := p.LaunchLocalForwarder(); err != nil {
106 | return err
107 | }
108 | logrus.Debug("Successfully restarted proxy-login-automator.")
109 | return nil
110 | }
111 |
112 | // StopForwarderProxy stop current running instance of proxy-login-automator
113 | func (p *Proxy) StopForwarderProxy() {
114 | logrus.Debug("Stopping proxy-login-automator...")
115 | if p.running && p.stopProxyForwarderChan != nil {
116 | p.stopProxyForwarderChan <- true
117 | } else {
118 | logrus.Debug("proxy-login-automator isn't running.")
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/internal/scrapper/scrapper.go:
--------------------------------------------------------------------------------
1 | package scrapper
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "time"
7 |
8 | "github.com/hbollon/igopher/internal/config/types"
9 | "github.com/hbollon/igopher/internal/utils"
10 | "github.com/hbollon/igopher/internal/xpath"
11 | "github.com/sirupsen/logrus"
12 | "github.com/tebeka/selenium"
13 | "github.com/vbauerster/mpb/v6"
14 | "github.com/vbauerster/mpb/v6/decor"
15 | )
16 |
17 | // FetchUsersFromUserFollowers scrap username list from users followers.
18 | // Source accounts and quantity are set by the bot user.
19 | func FetchUsersFromUserFollowers(bot *types.IGopher) ([]string, error) {
20 | logrus.Info("Fetching users from user's followers...")
21 |
22 | var igUsers []string
23 | // Valid configuration checking before fetching process
24 | if len(bot.ScrapperManager.SrcAccounts) == 0 || bot.ScrapperManager.SrcAccounts == nil {
25 | return nil, errors.New("No source users are set, please check your scrapper settings and retry")
26 | }
27 | if bot.ScrapperManager.Quantity <= 0 {
28 | return nil, errors.New("Scrapping quantity is null or negative, please check your scrapper settings and retry")
29 | }
30 |
31 | p := mpb.New(
32 | mpb.WithWidth(60),
33 | mpb.WithRefreshRate(180*time.Millisecond),
34 | )
35 | totalBar := p.Add(int64(len(bot.ScrapperManager.SrcAccounts)),
36 | mpb.NewBarFiller("[=>-|"),
37 | mpb.BarRemoveOnComplete(),
38 | mpb.PrependDecorators(
39 | decor.CountersNoUnit("%d / %d"),
40 | ),
41 | mpb.AppendDecorators(
42 | decor.Percentage(),
43 | ),
44 | )
45 |
46 | for _, srcUsername := range bot.ScrapperManager.SrcAccounts {
47 | logrus.Debugf("Fetch from '%s' user", srcUsername)
48 | finded, err := navigateUserFollowersList(bot, srcUsername)
49 | if !finded || err != nil {
50 | totalBar.IncrBy(1)
51 | continue
52 | }
53 |
54 | userBar := p.Add(int64(bot.ScrapperManager.Quantity),
55 | mpb.NewBarFiller("[=>-|"),
56 | mpb.BarRemoveOnComplete(),
57 | mpb.PrependDecorators(
58 | decor.Name(fmt.Sprintf("Scrapping users from %s account: ", srcUsername)),
59 | decor.CountersNoUnit("%d / %d"),
60 | ),
61 | mpb.AppendDecorators(
62 | decor.Percentage(),
63 | ),
64 | )
65 |
66 | // Scrap users until it has the right amount defined in ScrapperManager.Quantity by the user
67 | var scrappedUsers []selenium.WebElement
68 | for len(scrappedUsers) < bot.ScrapperManager.Quantity {
69 | if len(scrappedUsers) != 0 {
70 | // Scroll to the end of the list to gather more followers from ig
71 | _, err = bot.SeleniumStruct.WebDriver.ExecuteScript("window.scrollTo(0, document.body.scrollHeight);", nil)
72 | if err != nil {
73 | logrus.Warnf(
74 | "Error during followers dialog box scroll for '%s' user. The user certainly did not have enough followers for the request",
75 | srcUsername,
76 | )
77 | userBar.Abort(true)
78 | break
79 | }
80 | }
81 | utils.RandomSleepCustom(3, 4)
82 | scrappedUsers, err = bot.SeleniumStruct.GetElements(xpath.XPathSelectors["profile_followers_list"], "xpath")
83 | if err != nil {
84 | logrus.Errorf(
85 | "Error during users scrapping from followers dialog box for '%s' user",
86 | srcUsername,
87 | )
88 | userBar.Abort(true)
89 | break
90 | }
91 | scrappedUsers = bot.Blacklist.FilterScrappedUsers(scrappedUsers)
92 | userBar.SetCurrent(int64(len(scrappedUsers)))
93 | logrus.Debugf("Users count finded: %d", len(scrappedUsers))
94 | }
95 |
96 | if len(scrappedUsers) != 0 {
97 | for _, user := range scrappedUsers {
98 | username, err := user.Text()
99 | if err == nil {
100 | igUsers = append(igUsers, username)
101 | }
102 | }
103 | }
104 |
105 | logrus.Debugf("Scrapped users: %v\n", igUsers)
106 | if !userBar.Completed() {
107 | userBar.Abort(true)
108 | }
109 | totalBar.IncrBy(1)
110 | }
111 | p.Wait()
112 | if len(igUsers) == 0 {
113 | return nil, errors.New("Empty users result")
114 | }
115 | return igUsers, nil
116 | }
117 |
118 | // Go to user followers list with webdriver
119 | func navigateUserFollowersList(bot *types.IGopher, srcUsername string) (bool, error) {
120 | // Navigate to Instagram user page
121 | if err := bot.SeleniumStruct.WebDriver.Get(fmt.Sprintf("https://www.instagram.com/%s/?hl=en", srcUsername)); err != nil {
122 | logrus.Warnf("Requested user '%s' doesn't exist, skip it", srcUsername)
123 | return false, errors.New("Error during access to requested user")
124 | }
125 | utils.RandomSleepCustom(1, 3)
126 | // Access to followers list view
127 | if find, err := bot.SeleniumStruct.WaitForElement(xpath.XPathSelectors["profile_followers_button"], "xpath", 10); err == nil && find {
128 | elem, _ := bot.SeleniumStruct.GetElement(xpath.XPathSelectors["profile_followers_button"], "xpath")
129 | elem.Click()
130 | logrus.Debug("Clicked on user followers list")
131 | } else {
132 | return true, errors.New("Error during access to user followers list")
133 | }
134 |
135 | return true, nil
136 | }
137 |
--------------------------------------------------------------------------------
/internal/simulation/human.go:
--------------------------------------------------------------------------------
1 | package simulation
2 |
3 | import (
4 | "github.com/hbollon/igopher/internal/utils"
5 | "github.com/sirupsen/logrus"
6 | "github.com/tebeka/selenium"
7 | )
8 |
9 | // SimulateHandWriting simulate human writing by typing input string character by character with random interruptions
10 | // between letters
11 | func SimulateHandWriting(element selenium.WebElement, input string) bool {
12 | var err error
13 | if err = element.Click(); err == nil {
14 | for _, c := range input {
15 | if err = element.SendKeys(string(c)); err != nil {
16 | logrus.Debug("Unable to send key during message typing")
17 | logrus.Errorf("Error during message sending: %v", err)
18 | return false
19 | }
20 | utils.RandomSleepCustom(0.25, 1.0)
21 | }
22 | return true
23 | }
24 | logrus.Debug("Can't click on user searchbar")
25 | logrus.Errorf("Error during message sending: %v", err)
26 | return false
27 | }
28 |
--------------------------------------------------------------------------------
/internal/tui/genericMenu.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | log "github.com/sirupsen/logrus"
8 | )
9 |
10 | func (m model) UpdateGenericMenu(msg tea.Msg) (model, tea.Cmd) {
11 | switch msg := msg.(type) {
12 | case tea.KeyMsg:
13 | switch msg.String() {
14 | case ctrlC:
15 | return m, tea.Quit
16 |
17 | case ctrlB:
18 | m.screen = settingsMenu
19 |
20 | case up, "k":
21 | if m.genericMenuScreen.cursor > 0 {
22 | m.genericMenuScreen.cursor--
23 | }
24 |
25 | case down, "j":
26 | if m.genericMenuScreen.cursor < len(m.genericMenuScreen.choices)-1 {
27 | m.genericMenuScreen.cursor++
28 | }
29 |
30 | case enter:
31 | switch m.genericMenuScreen.cursor {
32 | case 0:
33 | switch m.settingsChoice {
34 | case autodmSettingsMenu:
35 | m.settingsChoice = autodmEnablingSettings
36 | case autodmGreetingMenu:
37 | m.settingsChoice = autodmGreetingEnablingSettings
38 | case quotasSettingsMenu:
39 | m.settingsChoice = quotasEnablingSettings
40 | case scheduleSettingsMenu:
41 | m.settingsChoice = scheduleEnablingSettings
42 | default:
43 | log.Warn("Invalid input!")
44 | }
45 | m.screen = settingsBoolScreen
46 | case 1:
47 | switch m.settingsChoice {
48 | case autodmSettingsMenu:
49 | m.settingsInputsScreen = getAutoDmSettings()
50 | m.settingsChoice = autodmSettings
51 | case autodmGreetingMenu:
52 | m.settingsInputsScreen = getAutoDmGreetingSettings()
53 | m.settingsChoice = autodmGreetingSettings
54 | case quotasSettingsMenu:
55 | m.settingsInputsScreen = getQuotasSettings()
56 | m.settingsChoice = quotasSettings
57 | case scheduleSettingsMenu:
58 | m.settingsInputsScreen = getSchedulerSettings()
59 | m.settingsChoice = scheduleSettings
60 | default:
61 | log.Warn("Invalid input!")
62 | }
63 | m.screen = settingsInputsScreen
64 | default:
65 | log.Warn("Invalid input!")
66 | }
67 | }
68 | }
69 | return m, nil
70 | }
71 |
72 | func (m model) ViewGenericMenu() string {
73 | s := "\n\n"
74 | for i, choice := range m.genericMenuScreen.choices {
75 | cursor := " "
76 | if m.genericMenuScreen.cursor == i {
77 | cursor = cursorColor(">")
78 | }
79 | s += fmt.Sprintf("%s %s\n", cursor, choice)
80 | }
81 |
82 | s += subtle("\nup/down: select") + dot + subtle("enter: choose") + dot + subtle("ctrl+b: back") + dot + subtle("ctrl+c: quit")
83 | return s
84 | }
85 |
--------------------------------------------------------------------------------
/internal/tui/homePage.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | conf "github.com/hbollon/igopher/internal/config"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | func (m model) UpdateHomePage(msg tea.Msg) (model, tea.Cmd) {
12 | switch msg := msg.(type) {
13 | case tea.KeyMsg:
14 | switch msg.String() {
15 | case ctrlC:
16 | return m, tea.Quit
17 |
18 | case up, "k":
19 | if m.homeScreen.cursor > 0 {
20 | m.homeScreen.cursor--
21 | }
22 |
23 | case down, "j":
24 | if m.homeScreen.cursor < len(m.homeScreen.choices)-1 {
25 | m.homeScreen.cursor++
26 | }
27 |
28 | case enter:
29 | errorMessage = ""
30 | infoMessage = ""
31 | switch m.homeScreen.cursor {
32 | case 0:
33 | return launchBot(m)
34 | case 1:
35 | config = conf.ImportConfig()
36 | m.screen = settingsMenu
37 | case 2:
38 | m.screen = settingsResetMenu
39 | case 3:
40 | if m.instanceAlreadyRunning {
41 | m.screen = stopRunningInstance
42 | } else {
43 | return m, tea.Quit
44 | }
45 | case 4:
46 | if m.instanceAlreadyRunning {
47 | return m, tea.Quit
48 | }
49 | log.Warn("Invalid input!")
50 | default:
51 | log.Warn("Invalid input!")
52 | }
53 | }
54 | }
55 | return m, nil
56 | }
57 |
58 | func (m model) ViewHomePage() string {
59 | s := fmt.Sprintf("\n🦄 Welcome to %s, the (soon) most powerful and versatile %s bot!\n\n", keyword("IGopher"), keyword("Instagram"))
60 | if errorMessage != "" {
61 | s += errorColor(errorMessage)
62 | } else {
63 | s += infoColor(infoMessage)
64 | }
65 |
66 | for i, choice := range m.homeScreen.choices {
67 | cursor := " "
68 | if m.homeScreen.cursor == i {
69 | cursor = cursorColor(">")
70 | }
71 | s += fmt.Sprintf("%s %s\n", cursor, choice)
72 | }
73 |
74 | s += subtle("\nup/down: select") + dot + subtle("enter: choose") + dot + subtle("ctrl+c: quit")
75 | return s
76 | }
77 |
78 | func (m *model) updateMenuItemsHomePage() {
79 | if m.instanceAlreadyRunning {
80 | m.homeScreen.choices = []string{"🚀 - Launch!", "🔧 - Configure", "🧨 - Reset settings", "☠️ - Stop running instance", "🚪 - Exit"}
81 | } else {
82 | m.homeScreen.choices = []string{"🚀 - Launch!", "🔧 - Configure", "🧨 - Reset settings", "🚪 - Exit"}
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/internal/tui/settingsBoolScreen.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | log "github.com/sirupsen/logrus"
8 | )
9 |
10 | func (m model) UpdateSettingsBoolMenu(msg tea.Msg) (model, tea.Cmd) {
11 | switch msg := msg.(type) {
12 | case tea.KeyMsg:
13 | switch msg.String() {
14 | case ctrlC:
15 | return m, tea.Quit
16 |
17 | case ctrlB:
18 | m.screen = settingsMenu
19 |
20 | case up, "k":
21 | if m.settingsTrueFalseScreen.cursor > 0 {
22 | m.settingsTrueFalseScreen.cursor--
23 | }
24 |
25 | case down, "j":
26 | if m.settingsTrueFalseScreen.cursor < len(m.settingsTrueFalseScreen.choices)-1 {
27 | m.settingsTrueFalseScreen.cursor++
28 | }
29 |
30 | case enter:
31 | switch m.settingsTrueFalseScreen.cursor {
32 | case 0:
33 | switch m.settingsChoice {
34 | case autodmEnablingSettings:
35 | config.AutoDm.Activated = true
36 |
37 | case autodmGreetingEnablingSettings:
38 | config.AutoDm.Greeting.Activated = true
39 |
40 | case quotasEnablingSettings:
41 | config.Quotas.Activated = true
42 |
43 | case scheduleEnablingSettings:
44 | config.Schedule.Activated = true
45 |
46 | case blacklistEnablingSettings:
47 | config.Blacklist.Activated = true
48 |
49 | default:
50 | log.Error("Unexpected settings screen value!")
51 | }
52 | m.screen = settingsMenu
53 | case 1:
54 | switch m.settingsChoice {
55 | case autodmEnablingSettings:
56 | config.AutoDm.Activated = false
57 |
58 | case autodmGreetingEnablingSettings:
59 | config.AutoDm.Greeting.Activated = false
60 |
61 | case quotasEnablingSettings:
62 | config.Quotas.Activated = false
63 |
64 | case scheduleEnablingSettings:
65 | config.Schedule.Activated = false
66 |
67 | case blacklistEnablingSettings:
68 | config.Blacklist.Activated = false
69 |
70 | default:
71 | log.Error("Unexpected settings screen value!")
72 | }
73 | m.screen = settingsMenu
74 | default:
75 | log.Warn("Invalid input!")
76 | m.screen = settingsMenu
77 | }
78 | }
79 | }
80 | return m, nil
81 | }
82 |
83 | func (m model) ViewSettingsBoolMenu() string {
84 | var s string
85 | switch m.settingsChoice {
86 | case autodmEnablingSettings:
87 | s = fmt.Sprintf("\nDo you want to enable %s module? (Default: %s)\n\n", keyword("AutoDM"), keyword("true"))
88 |
89 | case autodmGreetingEnablingSettings:
90 | s = fmt.Sprintf("\nDo you want to enable %s sub-module with %s? (Default: %s)\n\n",
91 | keyword("Greeting"), keyword("AutoDm"), keyword("true"))
92 |
93 | case quotasEnablingSettings:
94 | s = fmt.Sprintf("\nDo you want to enable %s module? (Default: %s)\n\n", keyword("Quotas"), keyword("true"))
95 |
96 | case scheduleEnablingSettings:
97 | s = fmt.Sprintf("\nDo you want to enable %s module? (Default: %s)\n\n", keyword("Scheduler"), keyword("true"))
98 |
99 | case blacklistEnablingSettings:
100 | s = fmt.Sprintf("\nDo you want to enable %s module? (Default: %s)\n\n", keyword("User Blacklist"), keyword("true"))
101 |
102 | default:
103 | log.Error("Unexpected settings screen value!")
104 | s = ""
105 | }
106 |
107 | for i, choice := range m.settingsTrueFalseScreen.choices {
108 | cursor := " "
109 | if m.settingsTrueFalseScreen.cursor == i {
110 | cursor = cursorColor(">")
111 | }
112 | s += fmt.Sprintf("%s %s\n", cursor, choice)
113 | }
114 | s += subtle("\nup/down: select") + dot + subtle("enter: choose") + dot + subtle("ctrl+b: back") + dot + subtle("ctrl+c: quit")
115 |
116 | return s
117 | }
118 |
--------------------------------------------------------------------------------
/internal/tui/settingsInputsScreen.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strconv"
5 | "strings"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | conf "github.com/hbollon/igopher/internal/config/types"
9 | log "github.com/sirupsen/logrus"
10 | )
11 |
12 | const invalidInputMsg = "Invalid input, please check all fields.\n\n"
13 |
14 | func (m model) UpdateSettingsInputsMenu(msg tea.Msg) (model, tea.Cmd) {
15 | switch msg := msg.(type) {
16 | case tea.KeyMsg:
17 | switch msg.String() {
18 | case ctrlC:
19 | return m, tea.Quit
20 |
21 | case ctrlB:
22 | errorMessage = ""
23 | m.screen = settingsMenu
24 |
25 | case enter:
26 | if m.settingsInputsScreen.index == len(m.settingsInputsScreen.input) {
27 | switch m.settingsChoice {
28 | case accountSettings:
29 | acc := conf.AccountYaml{
30 | Username: m.settingsInputsScreen.input[0].Value(),
31 | Password: m.settingsInputsScreen.input[1].Value(),
32 | }
33 | err := validate.Struct(acc)
34 | if err != nil {
35 | errorMessage = invalidInputMsg
36 | break
37 | }
38 | config.Account = acc
39 | errorMessage = ""
40 | m.screen = settingsMenu
41 | case scrappingSettings:
42 | val, err := strconv.Atoi(m.settingsInputsScreen.input[1].Value())
43 | if err == nil {
44 | scr := conf.ScrapperYaml{
45 | Accounts: strings.Split(m.settingsInputsScreen.input[0].Value(), ";"),
46 | Quantity: val,
47 | }
48 | err := validate.Struct(scr)
49 | if err != nil {
50 | errorMessage = invalidInputMsg
51 | break
52 | }
53 | config.SrcUsers = scr
54 | errorMessage = ""
55 | m.screen = settingsMenu
56 | } else {
57 | errorMessage = "Invalid quantity field, value must be numeric.\n\n"
58 | }
59 | case autodmSettings:
60 | dm := conf.AutoDmYaml{
61 | DmTemplates: strings.Split(m.settingsInputsScreen.input[0].Value(), ";"),
62 | }
63 | err := validate.Struct(dm)
64 | if err != nil {
65 | errorMessage = invalidInputMsg
66 | break
67 | }
68 | config.AutoDm.DmTemplates = dm.DmTemplates
69 | errorMessage = ""
70 | m.screen = settingsMenu
71 | case autodmGreetingSettings:
72 | gre := conf.GreetingYaml{
73 | Template: m.settingsInputsScreen.input[0].Value(),
74 | }
75 | err := validate.Struct(gre)
76 | if err != nil {
77 | errorMessage = invalidInputMsg
78 | break
79 | }
80 | config.AutoDm.Greeting.Template = gre.Template
81 | errorMessage = ""
82 | m.screen = settingsMenu
83 | case quotasSettings:
84 | dmDay, err := strconv.Atoi(m.settingsInputsScreen.input[0].Value())
85 | dmHour, err2 := strconv.Atoi(m.settingsInputsScreen.input[1].Value())
86 | if err == nil && err2 == nil {
87 | quo := conf.QuotasYaml{
88 | DmDay: dmDay,
89 | DmHour: dmHour,
90 | }
91 | err := validate.Struct(quo)
92 | if err != nil {
93 | errorMessage = invalidInputMsg
94 | break
95 | }
96 | config.Quotas.DmDay = quo.DmDay
97 | config.Quotas.DmHour = quo.DmHour
98 | errorMessage = ""
99 | m.screen = settingsMenu
100 | } else {
101 | errorMessage = invalidInputMsg
102 | }
103 | case scheduleSettings:
104 | sche := conf.ScheduleYaml{
105 | BeginAt: m.settingsInputsScreen.input[0].Value(),
106 | EndAt: m.settingsInputsScreen.input[1].Value(),
107 | }
108 | err := validate.Struct(sche)
109 | if err != nil {
110 | errorMessage = invalidInputMsg
111 | break
112 | }
113 | config.Schedule.BeginAt = sche.BeginAt
114 | config.Schedule.EndAt = sche.EndAt
115 | errorMessage = ""
116 | m.screen = settingsMenu
117 | default:
118 | log.Error("Unexpected settings screen value!\n\n")
119 | }
120 | break
121 | }
122 |
123 | // Cycle between inputs
124 | case "tab", shiftTab, up, down:
125 | s := msg.String()
126 |
127 | // Cycle indexes
128 | if s == up || s == shiftTab {
129 | m.settingsInputsScreen.index--
130 | } else {
131 | m.settingsInputsScreen.index++
132 | }
133 |
134 | if m.settingsInputsScreen.index > len(m.settingsInputsScreen.input) {
135 | m.settingsInputsScreen.index = 0
136 | } else if m.settingsInputsScreen.index < 0 {
137 | m.settingsInputsScreen.index = len(m.settingsInputsScreen.input)
138 | }
139 |
140 | for i := 0; i < len(m.settingsInputsScreen.input); i++ {
141 | if i == m.settingsInputsScreen.index {
142 | // Set focused state
143 | m.settingsInputsScreen.input[i].Focus()
144 | m.settingsInputsScreen.input[i].Prompt = focusedPrompt
145 | m.settingsInputsScreen.input[i].TextColor = focusedTextColor
146 | continue
147 | }
148 | // Remove focused state
149 | m.settingsInputsScreen.input[i].Blur()
150 | m.settingsInputsScreen.input[i].Prompt = blurredPrompt
151 | m.settingsInputsScreen.input[i].TextColor = ""
152 | }
153 |
154 | if m.settingsInputsScreen.index == len(m.settingsInputsScreen.input) {
155 | m.settingsInputsScreen.submitButton = focusedSubmitButton
156 | } else {
157 | m.settingsInputsScreen.submitButton = blurredSubmitButton
158 | }
159 |
160 | return m, nil
161 | }
162 | }
163 | // Handle character input and blinks
164 | m, cmd := updateInputs(msg, m)
165 | return m, cmd
166 | }
167 |
168 | func (m model) ViewSettingsInputsMenu() string {
169 | s := m.settingsInputsScreen.title
170 | s += errorColor(errorMessage)
171 | for i := 0; i < len(m.settingsInputsScreen.input); i++ {
172 | s += m.settingsInputsScreen.input[i].View()
173 | if i < len(m.settingsInputsScreen.input)-1 {
174 | s += "\n"
175 | }
176 | }
177 | s += "\n\n" + m.settingsInputsScreen.submitButton + "\n"
178 | s += subtle("\nup/down: select") + dot + subtle("enter: choose") + dot + subtle("ctrl+b: back") + dot + subtle("ctrl+c: quit")
179 | return s
180 | }
181 |
--------------------------------------------------------------------------------
/internal/tui/settingsMenu.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | conf "github.com/hbollon/igopher/internal/config"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | func (m model) UpdateSettingsMenu(msg tea.Msg) (model, tea.Cmd) {
12 | switch msg := msg.(type) {
13 | case tea.KeyMsg:
14 | switch msg.String() {
15 | case ctrlC:
16 | return m, tea.Quit
17 |
18 | case ctrlB:
19 | m.screen = mainMenu
20 |
21 | case up, "k":
22 | if m.configScreen.cursor > 0 {
23 | m.configScreen.cursor--
24 | }
25 |
26 | case down, "j":
27 | if m.configScreen.cursor < len(m.configScreen.choices)-1 {
28 | m.configScreen.cursor++
29 | }
30 |
31 | case enter:
32 | switch m.configScreen.cursor {
33 | case 0:
34 | m.settingsInputsScreen = getAccountSettings()
35 | m.screen = settingsInputsScreen
36 | m.settingsChoice = accountSettings
37 | case 1:
38 | m.settingsInputsScreen = getUsersScrappingSettings()
39 | m.screen = settingsInputsScreen
40 | m.settingsChoice = scrappingSettings
41 | case 2:
42 | m.genericMenuScreen = menu{choices: []string{"Enable/Disable Module", "Configuration"}}
43 | m.screen = genericMenu
44 | m.settingsChoice = autodmSettingsMenu
45 | case 3:
46 | m.genericMenuScreen = menu{choices: []string{"Enable/Disable Module", "Configuration"}}
47 | m.screen = genericMenu
48 | m.settingsChoice = autodmGreetingMenu
49 | case 4:
50 | m.genericMenuScreen = menu{choices: []string{"Enable/Disable Module", "Configuration"}}
51 | m.screen = genericMenu
52 | m.settingsChoice = quotasSettingsMenu
53 | case 5:
54 | m.genericMenuScreen = menu{choices: []string{"Enable/Disable Module", "Configuration"}}
55 | m.screen = genericMenu
56 | m.settingsChoice = scheduleSettingsMenu
57 | case 6:
58 | m.screen = settingsBoolScreen
59 | m.settingsChoice = blacklistEnablingSettings
60 | case 7:
61 | m.screen = settingsProxyScreen
62 | case 8:
63 | conf.ExportConfig(config)
64 | m.screen = mainMenu
65 | default:
66 | log.Warn("Invalid input!")
67 | }
68 | }
69 | }
70 | return m, nil
71 | }
72 |
73 | func (m model) ViewSettingsMenu() string {
74 | s := fmt.Sprintf("\nWhat would you like to %s?\n\n", keyword("tweak"))
75 |
76 | for i, choice := range m.configScreen.choices {
77 | cursor := " "
78 | if m.configScreen.cursor == i {
79 | cursor = cursorColor(">")
80 | }
81 | s += fmt.Sprintf("%s %s\n", cursor, choice)
82 | }
83 |
84 | s += subtle("\nup/down: select") + dot + subtle("enter: choose") + dot + subtle("ctrl+b: save & back") + dot + subtle("ctrl+c: quit")
85 | return s
86 | }
87 |
--------------------------------------------------------------------------------
/internal/tui/settingsProxy.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/hbollon/igopher/internal/proxy"
9 | )
10 |
11 | func (m model) UpdateSettingsProxy(msg tea.Msg) (model, tea.Cmd) {
12 | menuLength := len(m.settingsProxy.states) + len(m.settingsProxy.inputs)
13 | switch msg := msg.(type) {
14 | case tea.KeyMsg:
15 | switch msg.String() {
16 | case ctrlC:
17 | return m, tea.Quit
18 |
19 | case ctrlB:
20 | errorMessage = ""
21 | m.screen = settingsMenu
22 |
23 | case enter:
24 | switch m.settingsProxy.index {
25 | case 4:
26 | m.settingsProxy.states["Authentication"] = !m.settingsProxy.states["Authentication"]
27 |
28 | case 5:
29 | m.settingsProxy.states["Enabled"] = !m.settingsProxy.states["Enabled"]
30 |
31 | case menuLength:
32 | if m.settingsProxy.index == menuLength {
33 | port, _ := strconv.Atoi(m.settingsProxy.inputs[1].Value())
34 | proxy := proxy.Proxy{
35 | RemoteIP: m.settingsProxy.inputs[0].Value(),
36 | RemotePort: port,
37 | RemoteUsername: m.settingsProxy.inputs[2].Value(),
38 | RemotePassword: m.settingsProxy.inputs[3].Value(),
39 | WithAuth: m.settingsProxy.states["Authentication"],
40 | Enabled: m.settingsProxy.states["Enabled"],
41 | }
42 | err := validate.Struct(proxy)
43 | if err != nil {
44 | errorMessage = invalidInputMsg
45 | break
46 | }
47 | config.Selenium.Proxy = proxy
48 | errorMessage = ""
49 | m.screen = settingsMenu
50 | }
51 | }
52 |
53 | // Cycle between inputs
54 | case "tab", shiftTab, up, down:
55 | s := msg.String()
56 |
57 | // Cycle indexes
58 | if s == up || s == shiftTab {
59 | m.settingsProxy.index--
60 | } else {
61 | m.settingsProxy.index++
62 | }
63 |
64 | if m.settingsProxy.index > menuLength {
65 | m.settingsProxy.index = 0
66 | } else if m.settingsProxy.index < 0 {
67 | m.settingsProxy.index = menuLength
68 | }
69 |
70 | for i := 0; i < len(m.settingsProxy.inputs); i++ {
71 | if i == m.settingsProxy.index {
72 | // Set focused state
73 | m.settingsProxy.inputs[i].Focus()
74 | m.settingsProxy.inputs[i].Prompt = focusedPrompt
75 | m.settingsProxy.inputs[i].TextColor = focusedTextColor
76 | continue
77 | }
78 | // Remove focused state
79 | m.settingsProxy.inputs[i].Blur()
80 | m.settingsProxy.inputs[i].Prompt = blurredPrompt
81 | m.settingsProxy.inputs[i].TextColor = ""
82 | }
83 |
84 | if m.settingsProxy.index == menuLength {
85 | m.settingsProxy.submitButton = focusedSubmitButton
86 | } else {
87 | m.settingsProxy.submitButton = blurredSubmitButton
88 | }
89 |
90 | return m, nil
91 | }
92 | }
93 |
94 | // Handle character input and blinks
95 | m, cmd := updateInputsProxy(msg, m)
96 | return m, cmd
97 | }
98 |
99 | func updateInputsProxy(msg tea.Msg, m model) (model, tea.Cmd) {
100 | var (
101 | cmd tea.Cmd
102 | cmds []tea.Cmd
103 | )
104 |
105 | for i := 0; i < len(m.settingsProxy.inputs); i++ {
106 | m.settingsProxy.inputs[i], cmd = m.settingsProxy.inputs[i].Update(msg)
107 | cmds = append(cmds, cmd)
108 | }
109 |
110 | return m, tea.Batch(cmds...)
111 | }
112 |
113 | func (m model) ViewSettingsProxy() string {
114 | s := m.settingsProxy.title
115 | s += errorColor(errorMessage)
116 | for _, input := range m.settingsProxy.inputs {
117 | s += input.View()
118 | s += "\n\n"
119 | }
120 |
121 | if m.settingsProxy.index == 4 {
122 | s += fmt.Sprintf("%s : %s\n", focusColor("Authentication"), strconv.FormatBool(m.settingsProxy.states["Authentication"]))
123 | } else {
124 | s += fmt.Sprintf("%s : %s\n", "Authentication", strconv.FormatBool(m.settingsProxy.states["Authentication"]))
125 | }
126 |
127 | if m.settingsProxy.index == 5 {
128 | s += fmt.Sprintf("%s : %s\n", focusColor("Enabled"), strconv.FormatBool(m.settingsProxy.states["Enabled"]))
129 | } else {
130 | s += fmt.Sprintf("%s : %s\n", "Enabled", strconv.FormatBool(m.settingsProxy.states["Enabled"]))
131 | }
132 |
133 | s += "\n" + m.settingsProxy.submitButton + "\n"
134 | s += subtle("\nup/down: select") + dot + subtle("enter: choose/enable/disable") +
135 | dot + subtle("ctrl+b: back") + dot + subtle("ctrl+c: quit")
136 | return s
137 | }
138 |
--------------------------------------------------------------------------------
/internal/tui/settingsResetMenu.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | conf "github.com/hbollon/igopher/internal/config"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | func (m model) UpdateSettingsResetMenu(msg tea.Msg) (model, tea.Cmd) {
12 | switch msg := msg.(type) {
13 | case tea.KeyMsg:
14 | switch msg.String() {
15 | case ctrlC:
16 | return m, tea.Quit
17 |
18 | case ctrlB:
19 | m.screen = mainMenu
20 |
21 | case up, "k":
22 | if m.configResetScreen.cursor > 0 {
23 | m.configResetScreen.cursor--
24 | }
25 |
26 | case down, "j":
27 | if m.configResetScreen.cursor < len(m.configResetScreen.choices)-1 {
28 | m.configResetScreen.cursor++
29 | }
30 |
31 | case enter:
32 | switch m.configResetScreen.cursor {
33 | case 0:
34 | config = conf.ResetBotConfig()
35 | conf.ExportConfig(config)
36 | m.screen = mainMenu
37 | case 1:
38 | m.screen = mainMenu
39 | default:
40 | log.Warn("Invalid input!")
41 | }
42 | }
43 | }
44 | return m, nil
45 | }
46 |
47 | func (m model) ViewSettingsResetMenu() string {
48 | s := fmt.Sprintf("\nAre you sure you want to %s the default %s? This operation cannot be undone!\n\n",
49 | keyword("reset"), keyword("settings"))
50 |
51 | for i, choice := range m.configResetScreen.choices {
52 | cursor := " "
53 | if m.configResetScreen.cursor == i {
54 | cursor = cursorColor(">")
55 | }
56 | s += fmt.Sprintf("%s %s\n", cursor, choice)
57 | }
58 |
59 | s += subtle("\nup/down: select") + dot + subtle("enter: choose") + dot + subtle("ctrl+b: back") + dot + subtle("ctrl+c: quit")
60 | return s
61 | }
62 |
--------------------------------------------------------------------------------
/internal/tui/stopRunningProcess.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | "github.com/hbollon/igopher/internal/process"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | func (m model) UpdateStopRunningProcess(msg tea.Msg) (model, tea.Cmd) {
12 | switch msg := msg.(type) {
13 | case tea.KeyMsg:
14 | switch msg.String() {
15 | case ctrlC:
16 | return m, tea.Quit
17 |
18 | case ctrlB:
19 | m.screen = mainMenu
20 |
21 | case up, "k":
22 | if m.stopRunningProcessScreen.cursor > 0 {
23 | m.stopRunningProcessScreen.cursor--
24 | }
25 |
26 | case down, "j":
27 | if m.stopRunningProcessScreen.cursor < len(m.stopRunningProcessScreen.choices)-1 {
28 | m.stopRunningProcessScreen.cursor++
29 | }
30 |
31 | case enter:
32 | switch m.stopRunningProcessScreen.cursor {
33 | case 0:
34 | if err := process.TerminateRunningInstance(); err != nil {
35 | errorMessage = "Failed to terminate running IGopher instance! If the problem persist try to manually kill it or restart your computer."
36 | } else {
37 | infoMessage = "IGopher running instance has been successfully killed!" +
38 | " You can now run it again or close this TUI and restart IGopher as background task using \"--background-task\" flag.\n\n"
39 | m.instanceAlreadyRunning = false
40 | m.updateMenuItemsHomePage()
41 | }
42 | m.screen = mainMenu
43 | case 1:
44 | m.screen = mainMenu
45 | default:
46 | log.Warn("Invalid input!")
47 | }
48 | }
49 | }
50 | return m, nil
51 | }
52 |
53 | func (m model) ViewStopRunningProcess() string {
54 | s := fmt.Sprintf("\nAn instance of %s is already running, do you want to end it and continue?\n\n",
55 | keyword("IGopher"))
56 |
57 | for i, choice := range m.stopRunningProcessScreen.choices {
58 | cursor := " "
59 | if m.stopRunningProcessScreen.cursor == i {
60 | cursor = cursorColor(">")
61 | }
62 | s += fmt.Sprintf("%s %s\n", cursor, choice)
63 | }
64 |
65 | s += subtle("\nup/down: select") + dot + subtle("enter: choose") + dot + subtle("ctrl+b: back") + dot + subtle("ctrl+c: quit")
66 | return s
67 | }
68 |
--------------------------------------------------------------------------------
/internal/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "math/rand"
5 | "os"
6 | "os/exec"
7 | "runtime"
8 | "time"
9 |
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | const (
14 | sleepMin = 3.0
15 | sleepMax = 5.0
16 | )
17 |
18 | var clear map[string]func() // Map storing clear funcs for different os
19 |
20 | func init() {
21 | // Initialize random engine
22 | rand.Seed(time.Now().UTC().UnixNano())
23 |
24 | // Prepare terminal cleaning functions for all os
25 | clear = make(map[string]func())
26 | clear["linux"] = func() {
27 | cmd := exec.Command("clear")
28 | cmd.Stdout = os.Stdout
29 | cmd.Run()
30 | }
31 | clear["windows"] = func() {
32 | cmd := exec.Command("cmd", "/c", "cls")
33 | cmd.Stdout = os.Stdout
34 | cmd.Run()
35 | }
36 | clear["darwin"] = clear["linux"]
37 | }
38 |
39 | // RandomSleep sleep random time between default sleepMin and sleepMax
40 | func RandomSleep() {
41 | time.Sleep(RandomMillisecondDuration(sleepMin, sleepMax))
42 | }
43 |
44 | // RandomSleepCustom sleep random time between custom values
45 | func RandomSleepCustom(min, max float64) {
46 | time.Sleep(RandomMillisecondDuration(min, max))
47 | }
48 |
49 | // RandomMillisecondDuration generate time duration (in milliseconds) between two limits (in seconds)
50 | func RandomMillisecondDuration(min, max float64) time.Duration {
51 | // Convert arguments (in seconds) to milliseconds
52 | min *= 1000
53 | max *= 1000
54 | return time.Duration(min+rand.Float64()*(max-min)) * time.Millisecond
55 | }
56 |
57 | // ClearTerminal clear current terminal session according to user OS
58 | func ClearTerminal() {
59 | value, ok := clear[runtime.GOOS] // runtime.GOOS -> linux, windows, darwin etc.
60 | if ok {
61 | value()
62 | } else {
63 | logrus.Errorf("Can't clear terminal, os unsupported !")
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/internal/xpath/xpath.go:
--------------------------------------------------------------------------------
1 | package xpath
2 |
3 | var (
4 | // XPathSelectors is a map regrouping all xpaths used by igopher
5 | // to find elements on the web page.
6 | // Also contains some elements names.
7 | XPathSelectors = map[string]string{
8 | // Login page
9 | "login_username": "username",
10 | "login_password": "password",
11 | "login_button": "//button[text()='Log In']",
12 | "login_alternate_button": "//button/*[text()='Log In']",
13 | "login_accept_cookies": "//button[text()='Accept All' or text()='Allow essential and optional cookies']",
14 | "login_alternate_accept_cookies": "//button[text()='Allow All Cookies']",
15 | "login_information_saving": "//*[@aria-label='Home'] | //button[text()='Save Info'] | //button[text()='Not Now']",
16 |
17 | // DM related elements
18 | "dm_user_search": "//section/div[2]/div/div[1]/div/div[2]/input",
19 | "dm_placeholder": "//textarea[@placeholder]",
20 | "dm_send_button": "//button[text()='Send']",
21 |
22 | // DM user search elements
23 | "dm_profile_pictures_links": "//div[@aria-labelledby]/div/span//img[@alt]",
24 | "dm_next_button": "//button/*[text()='Next']",
25 |
26 | // Profile related elements
27 | "profile_followers_button": "//section/main/div/ul/li[2]/a",
28 | "profile_followers_list": "//*/li/div/div/div/div/a",
29 | }
30 | )
31 |
--------------------------------------------------------------------------------
/lib/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !init.go
3 | !.gitignore
--------------------------------------------------------------------------------
/resources/favicon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/favicon.icns
--------------------------------------------------------------------------------
/resources/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/favicon.ico
--------------------------------------------------------------------------------
/resources/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/favicon.png
--------------------------------------------------------------------------------
/resources/static/vue-igopher/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 | not dead
4 |
--------------------------------------------------------------------------------
/resources/static/vue-igopher/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true
5 | },
6 | 'extends': [
7 | 'plugin:vue/vue3-essential',
8 | 'eslint:recommended',
9 | '@vue/typescript/recommended'
10 | ],
11 | parserOptions: {
12 | ecmaVersion: 2020
13 | },
14 | rules: {
15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
17 | 'no-undef': 0,
18 | "@typescript-eslint/no-this-alias": ["off"],
19 | "@typescript-eslint/no-explicit-any": ["off"],
20 | },
21 | }
22 |
--------------------------------------------------------------------------------
/resources/static/vue-igopher/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/resources/static/vue-igopher/README.md:
--------------------------------------------------------------------------------
1 | # vue-igopher
2 |
3 | ## Project setup
4 | ```
5 | npm install
6 | ```
7 |
8 | ### Compiles and hot-reloads for development
9 | ```
10 | npm run serve
11 | ```
12 |
13 | ### Compiles and minifies for production
14 | ```
15 | npm run build
16 | ```
17 |
18 | ### Lints and fixes files
19 | ```
20 | npm run lint
21 | ```
22 |
23 | ### Customize configuration
24 | See [Configuration Reference](https://cli.vuejs.org/config/).
25 |
--------------------------------------------------------------------------------
/resources/static/vue-igopher/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/resources/static/vue-igopher/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-igopher",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "./",
6 | "scripts": {
7 | "serve": "vue-cli-service serve",
8 | "build": "vue-cli-service build --dest dist/app",
9 | "lint": "vue-cli-service lint"
10 | },
11 | "dependencies": {
12 | "@vueform/multiselect": "^2.1.0",
13 | "core-js": "^3.6.5",
14 | "mitt": "^2.1.0",
15 | "sweetalert2": "^11.0.18",
16 | "vue": "^3.0.11",
17 | "vue-class-component": "^8.0.0-0",
18 | "vue-router": "^4.0.0-0"
19 | },
20 | "devDependencies": {
21 | "@typescript-eslint/eslint-plugin": "^4.18.0",
22 | "@typescript-eslint/parser": "^4.18.0",
23 | "@vue/cli-plugin-babel": "~4.5.0",
24 | "@vue/cli-plugin-eslint": "~4.5.0",
25 | "@vue/cli-plugin-router": "~4.5.0",
26 | "@vue/cli-plugin-typescript": "~4.5.0",
27 | "@vue/cli-service": "~4.5.0",
28 | "@vue/compiler-sfc": "^3.0.0",
29 | "@vue/eslint-config-typescript": "^7.0.0",
30 | "eslint": "^6.7.2",
31 | "eslint-plugin-vue": "^7.0.0",
32 | "node-sass": "^6.0.1",
33 | "sass-loader": "^10.3.1",
34 | "typescript": "~4.1.5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/favicon.ico
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/FontAwesome.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/FontAwesome.otf
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-brands-400.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-brands-400.eot
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-brands-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-brands-400.ttf
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-brands-400.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-brands-400.woff
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-brands-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-brands-400.woff2
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-regular-400.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-regular-400.eot
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-regular-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-regular-400.ttf
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-regular-400.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-regular-400.woff
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-regular-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-regular-400.woff2
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-solid-900.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-solid-900.eot
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-solid-900.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-solid-900.ttf
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-solid-900.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-solid-900.woff
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fa-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fa-solid-900.woff2
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fontawesome-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fontawesome-webfont.eot
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fontawesome-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fontawesome-webfont.ttf
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fontawesome-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fontawesome-webfont.woff
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fontawesome-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Honest0/IGopher/3a86b6e4d50343ad8e2646a51757288d3ccc4d57/resources/static/vue-igopher/public/fonts/fontawesome-webfont.woff2
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/fonts/fontawesome5-overrides.min.css:
--------------------------------------------------------------------------------
1 | .fab.fa-bitcoin:before{content:"\f379"}.far.fa-calendar:before,.fas.fa-calendar:before{content:"\f133"}.far.fa-clipboard:before,.fas.fa-clipboard:before{content:"\f328"}.fab.fa-facebook-f:before{content:"\f39e"}.fab.fa-google-plus:before{content:"\f2b3"}.fas.fa-hotel:before{content:"\f594"}.fab.fa-linkedin:before{content:"\f08c"}.fas.fa-reply:before{content:"\f3e5"}.fas.fa-thermometer:before{content:"\f491"}.fab.fa-vimeo:before{content:"\f40a"}.far.fa-window-close:before,.fas.fa-window-close:before{content:"\f410"}.fab.fa-youtube-square:before{content:"\f431"}
--------------------------------------------------------------------------------
/resources/static/vue-igopher/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | <%= htmlWebpackPlugin.options.title %>
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/resources/static/vue-igopher/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |