├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── stale.yml └── workflows │ ├── build.yml │ ├── bundler.yml │ ├── commitsar.yml │ ├── golangci-lint.yml │ └── release-please.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── cmd └── igopher │ ├── gui-bundler │ ├── .gitignore │ ├── bind.go │ ├── bundler.json │ ├── gui.go │ └── resources │ ├── gui │ └── gui.go │ └── tui │ ├── IGopherTUI_dev.go │ └── IGopherTUI_prod.go ├── config └── config.yaml.exemple ├── data └── .gitignore ├── doc ├── IGopher.png └── gifs │ ├── demo.gif │ ├── demo_gui.gif │ └── demo_tui.gif ├── go.mod ├── go.sum ├── internal ├── actions │ ├── dm.go │ └── login.go ├── automation │ └── bot.go ├── config │ ├── config.go │ ├── flags │ │ └── flags.go │ └── types │ │ └── types.go ├── dependency │ └── manager.go ├── engine │ └── engine.go ├── gui │ ├── comm │ │ └── comm.go │ ├── datatypes │ │ └── types.go │ └── messages.go ├── logger │ └── logger.go ├── modules │ ├── blacklist │ │ └── blacklist.go │ ├── quotas │ │ └── quotas.go │ └── scheduler │ │ └── scheduler.go ├── process │ ├── process.go │ ├── process_unix.go │ └── process_windows.go ├── proxy │ └── proxy.go ├── scrapper │ └── scrapper.go ├── simulation │ └── human.go ├── tui │ ├── genericMenu.go │ ├── homePage.go │ ├── settingsBoolScreen.go │ ├── settingsInputsScreen.go │ ├── settingsMenu.go │ ├── settingsProxy.go │ ├── settingsResetMenu.go │ ├── stopRunningProcess.go │ └── tui.go ├── utils │ └── utils.go └── xpath │ └── xpath.go ├── lib └── .gitignore ├── resources ├── favicon.icns ├── favicon.ico ├── favicon.png └── static │ └── vue-igopher │ ├── .browserslistrc │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── fonts │ │ ├── FontAwesome.otf │ │ ├── fa-brands-400.eot │ │ ├── fa-brands-400.svg │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.eot │ │ ├── fa-regular-400.svg │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.woff │ │ ├── fa-regular-400.woff2 │ │ ├── fa-solid-900.eot │ │ ├── fa-solid-900.svg │ │ ├── fa-solid-900.ttf │ │ ├── fa-solid-900.woff │ │ ├── fa-solid-900.woff2 │ │ ├── font-awesome.min.css │ │ ├── fontawesome-all.min.css │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.svg │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ ├── fontawesome-webfont.woff2 │ │ └── fontawesome5-overrides.min.css │ └── index.html │ ├── src │ ├── App.vue │ ├── bootstrap │ │ ├── css │ │ │ └── bootstrap.min.css │ │ └── js │ │ │ └── bootstrap.min.js │ ├── components │ │ ├── DmAutomationPanel.vue │ │ ├── DownloadTracking.vue │ │ ├── Footer.vue │ │ ├── LateralNav.vue │ │ ├── LogsPanel.vue │ │ ├── NavBar.vue │ │ └── SettingsPanel.vue │ ├── config.ts │ ├── main.ts │ ├── mixins │ │ └── titleMixin.ts │ ├── plugins │ │ └── astilectron.ts │ ├── router │ │ └── index.ts │ ├── shims-vue.d.ts │ ├── theme.ts │ └── views │ │ ├── About.vue │ │ ├── DmAutomation.vue │ │ ├── Logs.vue │ │ ├── NotFound.vue │ │ └── Settings.vue │ ├── tsconfig.json │ └── vue.config.js └── scripts ├── bundle.sh └── release.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [hbollon] 4 | ko_fi: hugobollon 5 | custom: ['paypal.me/hugobollon'] 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Error output** 27 | If applicable, copy and paste any log/error you get in the terminal or the application. 28 | 29 | **Desktop (please complete the following information):** 30 | - OS: [e.g. iOS] 31 | - Version [e.g. 22] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Pull request checklist 4 | 5 | Please check if your PR fulfills the following requirements: 6 | - [ ] I have read the [CONTRIBUTING](https://github.com/hbollon/IGopher/blob/master/CONTRIBUTING.md) doc 7 | - [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features) 8 | - [ ] Changelog updated 9 | - [ ] Build (`go build -v ./cmd/igopher/tui`) was run locally successfully 10 | - [ ] All GitHub Actions workflows passed successfully 11 | 12 | ## Pull request type 13 | 14 | 15 | 16 | 17 | 18 | Please check the type of change your PR introduces: 19 | - [ ] Bugfix 20 | - [ ] Feature 21 | - [ ] Code style update (formatting, renaming) 22 | - [ ] Refactoring (no functional changes, no api changes) 23 | - [ ] Build related changes 24 | - [ ] Documentation content changes 25 | - [ ] Other (please describe): 26 | 27 | ## What is the current behavior? 28 | 29 | 30 | Issue Number: N/A 31 | 32 | ## What is the new behavior? 33 | 34 | 35 | - 36 | - 37 | - 38 | 39 | ## Other information 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an Issue or Pull Request becomes stale 2 | daysUntilStale: 45 3 | 4 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 5 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 6 | daysUntilClose: 7 7 | 8 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 9 | onlyLabels: [] 10 | 11 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 12 | exemptLabels: 13 | - pinned 14 | - no-stale 15 | 16 | # Set to true to ignore issues in a project (defaults to false) 17 | exemptProjects: false 18 | 19 | # Set to true to ignore issues in a milestone (defaults to false) 20 | exemptMilestones: true 21 | 22 | # Set to true to ignore issues with an assignee (defaults to false) 23 | exemptAssignees: true 24 | 25 | # Label to use when marking as stale 26 | staleLabel: wontfix 27 | 28 | # Comment to post when marking as stale. Set to `false` to disable 29 | markComment: > 30 | This issue has been automatically marked as stale because it has not had 31 | recent activity. It will be closed if no further activity occurs. Thank you 32 | for your contributions. 33 | 34 | # Limit the number of actions per hour, from 1-30. Default is 30 35 | limitPerRun: 30 36 | 37 | # Limit to only `issues` or `pulls` 38 | only: issues 39 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | go-version: ["1.17.x"] 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v2 21 | 22 | - name: Download Go modules 23 | run: go mod download 24 | 25 | - name: Builds 26 | run: | 27 | go build -v -o ./IGopherTUI ./cmd/igopher/tui 28 | go build -v -o ./IGopherGUI ./cmd/igopher/gui 29 | go build -v -o ./IGopherGUI-Bundler ./cmd/igopher/gui-bundler -------------------------------------------------------------------------------- /.github/workflows/bundler.yml: -------------------------------------------------------------------------------- 1 | name: bundler 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | go-version: ["1.17.x"] 9 | os: [ubuntu-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Install Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | 22 | - name: Install Node 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: '14' 26 | 27 | - name: Download Go modules 28 | run: go mod download 29 | 30 | - name: Download and install go-astilectron-bundler 31 | run: | 32 | go get github.com/asticode/go-astilectron-bundler/... 33 | go install github.com/asticode/go-astilectron-bundler/astilectron-bundler 34 | 35 | - name: Download npm dependencies and build Vue project 36 | run: | 37 | cd resources/static/vue-igopher 38 | npm install 39 | npm run build 40 | 41 | - name: Bundle 42 | run: | 43 | cd cmd/igopher/gui-bundler 44 | mv bind.go bind.go.tmp 45 | astilectron-bundler -c bundler.json 46 | 47 | - name: Cleaning 48 | run: | 49 | cd cmd/igopher/gui-bundler 50 | rm bind_*.go windows.syso 51 | mv bind.go.tmp bind.go 52 | -------------------------------------------------------------------------------- /.github/workflows/commitsar.yml: -------------------------------------------------------------------------------- 1 | name: Commitsar 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | validate-commits: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out code into the Go module directory 10 | uses: actions/checkout@v1 11 | - name: Commitsar check 12 | uses: docker://aevea/commitsar -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | golangci: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-go@v2 12 | with: 13 | go-version: '1.17.7' 14 | - name: golangci-lint 15 | uses: golangci/golangci-lint-action@v3 16 | with: 17 | version: latest 18 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Release-please check 13 | id: release 14 | uses: google-github-actions/release-please-action@v3 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | release-type: go 18 | changelog-path: CHANGELOG.md 19 | changelog-types: '[{"type":"feat","section":"Features","hidden":false},{"type":"fix","section":"Bug Fixes","hidden":false},{"type":"chore","section":"Miscellaneous Changes","hidden":false}]' 20 | 21 | - name: Checkout codebase 22 | uses: actions/checkout@v2 23 | if: ${{ steps.release.outputs.release_created }} 24 | 25 | - name: Setup Go 1.18 26 | uses: actions/setup-go@v3 27 | if: ${{ steps.release.outputs.release_created }} 28 | with: 29 | go-version-file: 'go.mod' 30 | 31 | - name: Setup NodeJS 16 32 | uses: actions/setup-node@v3 33 | if: ${{ steps.release.outputs.release_created }} 34 | with: 35 | node-version: 16 36 | registry-url: 'https://registry.npmjs.org' 37 | 38 | - name: Build and package binaries 39 | if: ${{ steps.release.outputs.release_created }} 40 | run: make release 41 | 42 | - name: Upload Assets to Release 43 | uses: csexton/release-asset-action@v2 44 | if: ${{ steps.release.outputs.release_created }} 45 | with: 46 | pattern: "bin/*" 47 | github-token: ${{ secrets.GITHUB_TOKEN }} 48 | release-url: ${{ steps.release.outputs.upload_url }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all 2 | * 3 | 4 | # Unignore all with extensions 5 | !*.* 6 | 7 | # Unignore all dirs 8 | !*/ 9 | 10 | # Exept Makefile 11 | !Makefile 12 | 13 | ### Above combination will ignore all files without extension ### 14 | 15 | # Binaries for programs and plugins 16 | bin/ 17 | output/ 18 | *.exe 19 | *.exe~ 20 | *.dll 21 | *.so 22 | *.dylib 23 | *.syso 24 | 25 | # Bundler 26 | bind_*.go 27 | 28 | # Test binary, built with `go test -c` 29 | *.test 30 | 31 | # Output of the go coverage tool, specifically when used with LiteIDE 32 | *.out 33 | 34 | # Dependency directories (remove the comment below to include it) 35 | vendor/ 36 | 37 | # Config and generated files 38 | config/*.yaml 39 | 40 | # Editor/IDE files 41 | .idea/ 42 | .vscode/ 43 | 44 | # Log folder 45 | logs/ 46 | data/pid.txt 47 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | max-issues-per-linter: 0 6 | max-same-issues: 0 7 | 8 | linters-settings: 9 | goconst: 10 | min-len: 2 11 | min-occurrences: 3 12 | gocyclo: 13 | min-complexity: 30 # Will be decreased later in the devellopement 14 | gosimple: 15 | go: "1.17" 16 | checks: ["all"] 17 | govet: 18 | check-shadowing: true 19 | settings: 20 | printf: 21 | funcs: 22 | - (github.com/sirupsen/logrus).Debugf 23 | - (github.com/sirupsen/logrus).Infof 24 | - (github.com/sirupsen/logrus).Warnf 25 | - (github.com/sirupsen/logrus).Errorf 26 | - (github.com/sirupsen/logrus).Fatalf 27 | misspell: 28 | locale: US 29 | lll: 30 | line-length: 140 31 | revive: 32 | ignore-generated-header: false 33 | severity: warning 34 | confidence: 0.8 35 | errorCode: 1 36 | warningCode: 1 37 | rules: 38 | - name: blank-imports 39 | severity: warning 40 | - name: context-as-argument 41 | severity: warning 42 | - name: context-keys-type 43 | severity: warning 44 | - name: cyclomatic 45 | severity: warning 46 | arguments: 47 | - 30 # Maximum cyclomatic complexity 48 | - name: error-return 49 | severity: warning 50 | - name: error-strings 51 | severity: warning 52 | - name: error-naming 53 | severity: warning 54 | - name: exported 55 | severity: warning 56 | - name: if-return 57 | severity: warning 58 | - name: increment-decrement 59 | severity: warning 60 | - name: var-naming 61 | severity: warning 62 | - name: var-declaration 63 | severity: warning 64 | - name: package-comments 65 | severity: warning 66 | - name: range 67 | severity: warning 68 | - name: receiver-naming 69 | severity: warning 70 | - name: time-naming 71 | severity: warning 72 | - name: unexported-return 73 | severity: warning 74 | - name: indent-error-flow 75 | severity: warning 76 | - name: errorf 77 | severity: warning 78 | - name: empty-block 79 | severity: warning 80 | - name: superfluous-else 81 | severity: warning 82 | - name: unreachable-code 83 | severity: warning 84 | - name: redefines-builtin-id 85 | severity: warning 86 | staticcheck: 87 | go: "1.17" 88 | checks: ["all"] 89 | 90 | 91 | linters: 92 | disable-all: true 93 | enable: 94 | - dupl 95 | - exportloopref 96 | - goconst 97 | - gocyclo 98 | - godox 99 | - gofmt 100 | - goimports 101 | - gosimple 102 | - govet 103 | - ineffassign 104 | - lll 105 | - misspell 106 | - prealloc 107 | - revive 108 | - rowserrcheck 109 | - staticcheck 110 | - typecheck 111 | - unconvert 112 | - unparam 113 | - whitespace 114 | 115 | # Disabled during early devellopment 116 | # - deadcode 117 | # - errcheck 118 | # - gosec 119 | # - structcheck 120 | # - unused 121 | # - varcheck -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.4.2](https://github.com/hbollon/IGopher/compare/v0.4.1...v0.4.2) (2022-07-15) 8 | 9 | 10 | ### Bug Fixes 11 | 12 | * **xpath:** invalid xpath to select user to dm ([#30](https://github.com/hbollon/IGopher/issues/30)) ([760c171](https://github.com/hbollon/IGopher/commit/760c171b93f802707fb1ea7fa0805819a6d2331b)) 13 | 14 | 15 | ### Miscellaneous Changes 16 | 17 | * apply v0.4.1 changes to internals packages ([a50f4b0](https://github.com/hbollon/IGopher/commit/a50f4b07293d5a4493d6202d37148f1b4ccf4985)) 18 | * **makefile:** create .zip archives after build tasks on release ([549c602](https://github.com/hbollon/IGopher/commit/549c602a97bf48219a2f9e08a8dd87c235cb6731)) 19 | * update go module to 1.17 ([95ea3bf](https://github.com/hbollon/IGopher/commit/95ea3bf501179ac51111c3f8139ca02508cbe4bd)) 20 | 21 | ### [0.4.1](https://www.github.com/hbollon/IGopher/compare/v0.4.0...v0.4.1) (2022-03-21) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * invalid id selector in some xpaths ([#28](https://www.github.com/hbollon/IGopher/issues/28)) ([b220352](https://www.github.com/hbollon/IGopher/commit/b2203524025f8b94f4d46faae929e20001836c2d)) 27 | * issue with new cookie validation popup ([812551a](https://www.github.com/hbollon/IGopher/commit/812551a14f4a370834e0aada037ed4e52ead83b2)) 28 | 29 | ## [0.4.0](https://www.github.com/hbollon/IGopher/compare/v0.3.1...v0.4.0) (2022-01-30) 30 | 31 | 32 | ### Features 33 | 34 | * add dependencies hash checking during dependency check at startup ([664589e](https://www.github.com/hbollon/IGopher/commit/664589efc7aa7c72affaf1bde056eff916669712)) 35 | * add fixed versions for dependencies ([1a1e20f](https://www.github.com/hbollon/IGopher/commit/1a1e20fefd8798b21c8fb60350b9ba52d68a82de)) 36 | * **dependency-manager:** set fixed versions for all files and add hashes ([6eea031](https://www.github.com/hbollon/IGopher/commit/6eea031c4b97c5d7d7c1c90a9b0a48bd72fc8e1f)) 37 | * manifest file generation and checking ([5d0902f](https://www.github.com/hbollon/IGopher/commit/5d0902f359609df7a11573d197d85a471b41a7e6)) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * browser stuck after connexion on /#reactivated url ([b47e1b8](https://www.github.com/hbollon/IGopher/commit/b47e1b8d029cb6b419dac8816a4e497a2e259090)) 43 | * dependency search in manifest ([1a1e20f](https://www.github.com/hbollon/IGopher/commit/1a1e20fefd8798b21c8fb60350b9ba52d68a82de)) 44 | * **gui:** now handle 'bot crash' msg from Go ([592766a](https://www.github.com/hbollon/IGopher/commit/592766ae725157178f49c30da1249d6c5f7aaf4a)) 45 | * handle browser closing event and avoid crash ([70da812](https://www.github.com/hbollon/IGopher/commit/70da812a817f7333ed262173cc82a5ffaafe7926)) 46 | * **logs:** removed config dump from logs ([2c437dd](https://www.github.com/hbollon/IGopher/commit/2c437dd340971e834c6773de234df58c66835724)) 47 | * update xpath for scrapper and user search input ([408962a](https://www.github.com/hbollon/IGopher/commit/408962a4b5f02a235259c6bd10b3ccef152b10b8)) 48 | * update xpath for users scrap and search ([2be277b](https://www.github.com/hbollon/IGopher/commit/2be277bbd20628abc1a6821794c5ed1180b04676)) 49 | * **vue:** remove useless console log ([c776b8d](https://www.github.com/hbollon/IGopher/commit/c776b8d0871e0ddd3a358e625bb7ee24db55fe4e)) 50 | 51 | ### [0.3.1](https://www.github.com/hbollon/IGopher/compare/v0.3.0...v0.3.1) (2021-10-26) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * crash during followers list focus ([d020662](https://www.github.com/hbollon/IGopher/commit/d0206621e7548fb86e3950745461bed9797180fc)) 57 | * **engine:** add --incognito flag to chrome & update user-agent ([a20c0eb](https://www.github.com/hbollon/IGopher/commit/a20c0eba0d4b9d81f363cd647f03b5b8e012145c)) 58 | * **frontend:** launch/hotreload callbacks swiped ([596bc9a](https://www.github.com/hbollon/IGopher/commit/596bc9af65cd15564025cc2be61e0cdb71a17867)) 59 | * **frontend:** modal-backdrop not destroying on modal close ([4c5e1c8](https://www.github.com/hbollon/IGopher/commit/4c5e1c840b3f75f4474f45c6d45ceff01ffcc50d)) 60 | * now check for the second cookies prompt after login (not always present) ([d020662](https://www.github.com/hbollon/IGopher/commit/d0206621e7548fb86e3950745461bed9797180fc)) 61 | * **perf:** slow down goroutines loop to significantly reduce cpu usage ([f0d8739](https://www.github.com/hbollon/IGopher/commit/f0d87398cfe76fbc153be94fa513782832d857f0)) 62 | 63 | ## [0.3.0] - 2021-03-06 64 | ### Features 65 | * native proxy support configurable from both GUI/TUI 66 | * new `--background-task` flag for the TUI version to execute IGopher as background task! TUI is also capable to detect running tasks so to stop a background one relaunch TUI without the flag :) 67 | * rework all GUI's frontend to Vue 3 and Bootstrap 5 68 | * **bundler:** update to be compatible with new Vue binaries ([924e15f](https://www.github.com/hbollon/IGopher/commit/924e15f28db14a364bc8e13713836418696ca12e)) 69 | * **frontend:** now update radio state with current igopher config ([359431f](https://www.github.com/hbollon/IGopher/commit/359431fe532f91e8c91206f463a9a58cb4aaa3db)) 70 | * **frontend:** update scrapper's src users tag input with current config ([ec6aad3](https://www.github.com/hbollon/IGopher/commit/ec6aad31a4453c5ef24a3c60afd3f434076a4f5b)) 71 | * **gui:** add download tracking interface on bot launch ([61c4531](https://www.github.com/hbollon/IGopher/commit/61c45312a3d9a579ff6d091143ea39f448c6215e)) 72 | * **gui:** replace src users text field by tags input ([3c9d224](https://www.github.com/hbollon/IGopher/commit/3c9d2244f0ce7819379b0ec62c3d19835d1c8915)) 73 | * **scripts:** update bundle.sh to do npm operations ([ecfa656](https://www.github.com/hbollon/IGopher/commit/ecfa656c050e10779cbdfc7bb31d442fbfc9568d)) 74 | * **vuejs:** add logs view ([fae88b1](https://www.github.com/hbollon/IGopher/commit/fae88b1afec71e0fadf41600235ac0d6c341ea30)) 75 | * **vuejs:** add mixin to handle title property on views ([2afcf8e](https://www.github.com/hbollon/IGopher/commit/2afcf8ec88f2a7da39414f7b32317167142c13e0)) 76 | * **vuejs:** add settings view and controller with router config ([ac3550b](https://www.github.com/hbollon/IGopher/commit/ac3550b88e4d31acc90e6d9cd55f7983bf56a1cc)) 77 | * **vuejs:** convert DmAutomation component script to typescript ([50bd2ba](https://www.github.com/hbollon/IGopher/commit/50bd2ba3eb4f5cceff5e1240d052e01fb61b3c23))bad struct field access on msg listening 78 | 79 | * **vuejs:** remove old compatibility scripts for JQuery and obsoletes files ([33e6eb1](https://www.github.com/hbollon/IGopher/commit/33e6eb18b51850a823579f01fc8a03e54e2877f4)) 80 | * **vuejs:** replace old izitoast by sweetalert2 ([8323661](https://www.github.com/hbollon/IGopher/commit/8323661602ea3b71956a3c8564c65d2d52584d2a)) 81 | * **vuejs:** view for 404 errors and router configuration ([6f42449](https://www.github.com/hbollon/IGopher/commit/6f42449fa80277e7a760b45ffb513bd488d4a375)) 82 | 83 | ### Bug Fixes 84 | 85 | * **engine:** cleanup routine execution on electron window closing/crashing 86 | * **astor:** bad struct field access on msg listening ([61c4531](https://www.github.com/hbollon/IGopher/commit/61c45312a3d9a579ff6d091143ea39f448c6215e)) 87 | * **chrome:** DevToolsActivePort file doesn't exist error on linux ([#8](https://www.github.com/hbollon/IGopher/issues/8)) ([#10](https://www.github.com/hbollon/IGopher/issues/10)) ([32e66e9](https://www.github.com/hbollon/IGopher/commit/32e66e954277730f29560d327e1e22b5d7bbc9a8)) 88 | * **vuejs:** hook execution on route change caused by astilectron listener ([822554e](https://www.github.com/hbollon/IGopher/commit/822554e29686cb3e745372ea0ebf80c20de79393)) 89 | 90 | **The CHANGELOG and releases will now be automated by __release-please__ workflow.** 91 | 92 | ## [0.2.1] - 2021-03-06 93 | ### Added 94 | - [GUI] Information notification on bot stop/hot-reload 95 | ### Changed 96 | - Improve selenium closing routine 97 | - [Gui] Notification triggering on bot crash and bot running state reset 98 | - Set icons, single instance and version options to astilectron in gui development package 99 | - Replace custom scripts min by full ones for easier contributions 100 | - Allow bot exit before ig connection and scrapping process 101 | 102 | ### Fixed 103 | - Duplicate CloseSelenium call on bot stop 104 | - Scrapper issue if src user doesn't exist 105 | - Scrapper issue if src user is private 106 | - Scrapper issue if src user hasn't enough followers than requested 107 | - Abort blocking mpb progress bar on user fetching error 108 | - Clean go.mod 109 | ## [0.2.0] - 2021-03-04 110 | ### Added 111 | - Electron GUI with: 112 | - DM Automation config screen with launch/stop/hot-reload actions 113 | - Global settings view 114 | - Logs explorer 115 | - Logrus dual output on stdout with curom formatter and log file with json formatter 116 | - Bundler github workflow 117 | ### Changed 118 | - Parallelization of bot execution on several goroutines (once for engine and once for communication with main goroutine) with context/channels 119 | - IGopher architecture refactor 120 | 121 | ### Fixed 122 | - Fix project environment location issue #3 123 | - Linters related issues 124 | ## [0.1.3] - 2021-02-21 125 | ### Added 126 | - Useful repository files including: 127 | - CONTRIBUTING.md 128 | - Issues & PR templates 129 | - Changelog file 130 | 131 | ### Changed 132 | - Add new config & linters to golangci workflow 133 | 134 | ### Fixed 135 | - Chrome/ChromeDriver dependencies incompatibility issues with MacOS 136 | - Terminal cleaning issue with MacOS 137 | - Variable shadowing issues 138 | - goconst & lll linters related issues 139 | 140 | ## [0.1.2] - 2021-02-06 141 | ### Changed 142 | - Moved TUI to internal sub-package 143 | - Refactor TUI Update/View logic 144 | 145 | ### Fixed 146 | - Reduce cyclomatic complexities of some functions 147 | - Golint issues 148 | 149 | ## [0.1.1] - 2021-01-31 150 | ### Changed 151 | - Update README with better installation instructions 152 | 153 | ### Fixed 154 | - Issue with scrapper config model 155 | 156 | ## [0.1.0] - 2021-01-31 157 | IGopher come in this first public pre-release with cross-platform (Linux/Windows at the moment) compatibility and a user-friendly terminal user interface! 158 | At this point, the bot will first retrieve a user list from the followers of the source users that you have entered. It will then send a message according to the templates that you put to them. 159 | In addition, you can activate certain modules such as: 160 | 161 | - A heuristic and daily quota limiter 162 | - A scheduler 163 | - The use of a blacklist to avoid duplicates interactions 164 | 165 | [0.3.0]: https://github.com/hbollon/igopher/compare/v0.2.1...v0.3.0 166 | [0.2.1]: https://github.com/hbollon/igopher/compare/v0.2.0...v0.2.1 167 | [0.2.0]: https://github.com/hbollon/igopher/compare/v0.1.3...v0.2.0 168 | [0.1.3]: https://github.com/hbollon/igopher/compare/v0.1.2...v0.1.3 169 | [0.1.2]: https://github.com/hbollon/igopher/compare/v0.1.1...v0.1.2 170 | [0.1.1]: https://github.com/hbollon/igopher/compare/v0.1.0...v0.1.1 171 | [0.1.0]: https://github.com/hbollon/igopher/releases/tag/v0.1.0 172 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Ensure all GitHub Actions workflows passed successfully 13 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 14 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 15 | 4. Update CHANGELOG.md with your changes (check [this guide](https://changelog.md/)) 16 | 5. Update the README.md with details of changes to the interface, this includes new environment 17 | variables, exposed ports, useful file locations and container parameters. 18 | 6. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 19 | do not have permission to do that, you may request the second reviewer to merge it for you. 20 | 21 | ## Code of Conduct 22 | 23 | ### Our Pledge 24 | 25 | In the interest of fostering an open and welcoming environment, we as 26 | contributors and maintainers pledge to making participation in our project and 27 | our community a harassment-free experience for everyone, regardless of age, body 28 | size, disability, ethnicity, gender identity and expression, level of experience, 29 | nationality, personal appearance, race, religion, or sexual identity and 30 | orientation. 31 | 32 | ### Our Standards 33 | 34 | Examples of behavior that contributes to creating a positive environment 35 | include: 36 | 37 | * Using welcoming and inclusive language 38 | * Being respectful of differing viewpoints and experiences 39 | * Gracefully accepting constructive criticism 40 | * Focusing on what is best for the community 41 | * Showing empathy towards other community members 42 | 43 | Examples of unacceptable behavior by participants include: 44 | 45 | * The use of sexualized language or imagery and unwelcome sexual attention or 46 | advances 47 | * Trolling, insulting/derogatory comments, and personal or political attacks 48 | * Public or private harassment 49 | * Publishing others' private information, such as a physical or electronic 50 | address, without explicit permission 51 | * Other conduct which could reasonably be considered inappropriate in a 52 | professional setting 53 | 54 | ### Our Responsibilities 55 | 56 | Project maintainers are responsible for clarifying the standards of acceptable 57 | behavior and are expected to take appropriate and fair corrective action in 58 | response to any instances of unacceptable behavior. 59 | 60 | Project maintainers have the right and responsibility to remove, edit, or 61 | reject comments, commits, code, wiki edits, issues, and other contributions 62 | that are not aligned to this Code of Conduct, or to ban temporarily or 63 | permanently any contributor for other behaviors that they deem inappropriate, 64 | threatening, offensive, or harmful. 65 | 66 | ### Scope 67 | 68 | This Code of Conduct applies both within project spaces and in public spaces 69 | when an individual is representing the project or its community. Examples of 70 | representing a project or community include using an official project e-mail 71 | address, posting via an official social media account, or acting as an appointed 72 | representative at an online or offline event. Representation of a project may be 73 | further defined and clarified by project maintainers. 74 | 75 | ### Enforcement 76 | 77 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 78 | reported by contacting the project team at [hugo.bollon@gmail.com](mailto:hugo.bollon@gmail.com). All 79 | complaints will be reviewed and investigated and will result in a response that 80 | is deemed necessary and appropriate to the circumstances. The project team is 81 | obligated to maintain confidentiality with regard to the reporter of an incident. 82 | Further details of specific enforcement policies may be posted separately. 83 | 84 | Project maintainers who do not follow or enforce the Code of Conduct in good 85 | faith may face temporary or permanent repercussions as determined by other 86 | members of the project's leadership. 87 | 88 | ### Attribution 89 | 90 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 91 | available at [http://contributor-covenant.org/version/1/4][version] 92 | 93 | [homepage]: http://contributor-covenant.org 94 | [version]: http://contributor-covenant.org/version/1/4/ 95 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 Hugo Bollon 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := IGopher 2 | FILES := $(wildcard */*.go) 3 | VERSION := $(shell git describe --always) 4 | BIN_DIR := bin/ 5 | BUNDLE_DIR := cmd/igopher/gui-bundler/output/ 6 | VUE_DIST_DIR := resources/static/vue-igopher/dist/ 7 | 8 | export GO111MODULE=on 9 | 10 | ## setup: Install required libraries/tools for build tasks 11 | .PHONY: setup 12 | setup: 13 | @command -v goimports 2>&1 >/dev/null || GO111MODULE=off go get -u -v golang.org/x/tools/cmd/goimports 14 | @command -v golangci-lint 2>&1 >/dev/null || GO111MODULE=off go get -v github.com/golangci/golangci-lint/cmd/golangci-lint 15 | 16 | ## fmt: Format all sources files 17 | .PHONY: fmt 18 | fmt: setup 19 | goimports -w $(FILES) 20 | 21 | ## lint: Run all lint related tests against the codebase (will use the .golangci.yml config) 22 | .PHONY: lint 23 | lint: setup 24 | golangci-lint run 25 | 26 | ## test: Run the tests against the codebase 27 | .PHONY: test 28 | test: 29 | go test -v -race ./... 30 | 31 | ## build: Build the binary for Linux environement 32 | .PHONY: build 33 | build: 34 | env GOOS=linux GOARCH=amd64 \ 35 | go build \ 36 | -o ./bin/IGopherTUI-linux-amd64 ./cmd/igopher/tui 37 | 38 | ## build-all: Build binaries for all supported platforms 39 | .PHONY: build-all 40 | build-all: 41 | env GOOS=linux GOARCH=amd64 \ 42 | go build \ 43 | -o ./bin/IGopherTUI-linux-amd64 \ 44 | ./cmd/igopher/tui 45 | 46 | env GOOS=windows GOARCH=amd64 \ 47 | go build \ 48 | -o ./bin/IGopherTUI-windows-amd64.exe \ 49 | ./cmd/igopher/tui 50 | 51 | env GOOS=darwin GOARCH=amd64 \ 52 | go build \ 53 | -o ./bin/IGopherTUI-macOS-amd64 \ 54 | ./cmd/igopher/tui 55 | 56 | ## build-vue: Build VueJS project 57 | .PHONY: build-vue 58 | build-vue: 59 | @if [ @command -v npm 2>&1 >/dev/null ]; then \ 60 | @echo "Npm not found, install NodeJS and retry."; \ 61 | return; \ 62 | fi 63 | cd ./resources/static/vue-igopher && \ 64 | npm install && \ 65 | npm run build 66 | 67 | ## bundle: Create astilectron bundle for all supported platforms with embedded ressources 68 | .PHONY: bundle 69 | bundle: build-vue install 70 | go get github.com/asticode/go-astilectron-bundler/... 71 | go install github.com/asticode/go-astilectron-bundler/astilectron-bundler 72 | cd ./cmd/igopher/gui-bundler && \ 73 | mv bind.go bind.go.tmp && \ 74 | astilectron-bundler -c bundler.json && \ 75 | rm bind_*.go windows.syso && \ 76 | mv bind.go.tmp bind.go 77 | @echo "Done. Executables are located in 'cmd/igopher/gui-bundler/output/' folder" 78 | 79 | ## release: Build binaries for all platforms for both GUI and TUI 80 | .PHONY: release 81 | release: build-all bundle 82 | cd ./cmd/igopher/gui-bundler/output/ && \ 83 | zip -r ../../../../bin/IGopherGUI-linux-amd64.zip linux-amd64 && \ 84 | zip -r ../../../../bin/IGopherGUI-windows-amd64.zip windows-amd64 && \ 85 | zip -r ../../../../bin/IGopherGUI-darwin-amd64.zip darwin-amd64 86 | 87 | 88 | ## install: Install go dependencies 89 | .PHONY: install 90 | install: 91 | go get ./... 92 | 93 | # vendor: Vendor go modules 94 | .PHONY: vendor 95 | vendor: 96 | go mod vendor 97 | 98 | ## coverage: Generates coverage report 99 | .PHONY: coverage 100 | coverage: 101 | rm -f coverage.out 102 | go test -v ./... -coverpkg=./... -coverprofile=coverage.out 103 | 104 | ## clean: Remove binaries (go binaries, bundles and vue dist folder) if they exist 105 | .PHONY: clean 106 | clean: 107 | rm -rf $(BIN_DIR) 108 | rm -rf $(BUNDLE_DIR) 109 | rm -rf $(VUE_DIST_DIR) 110 | 111 | .PHONY: all 112 | all: lint test build-vue build-all bundle 113 | 114 | .PHONY: help 115 | all: help 116 | help: Makefile 117 | @echo 118 | @echo " Choose a command to run in "$(NAME)":" 119 | @echo 120 | @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' 121 | @echo 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

IGopher : (WIP) Golang smart bot for Instagram DM automation

2 |

3 | IGopher logo 4 |

5 |

6 | 7 | Build CI 8 | 9 | 10 | Go Report Card 11 | 12 | 13 | License: MIT 14 | 15 | 16 | PkgGoDev 17 | 18 |

19 | 20 |

⚡ 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 | 14 | 15 | 60 | 61 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/components/DownloadTracking.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/components/LateralNav.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/components/LogsPanel.vue: -------------------------------------------------------------------------------- 1 | 107 | 108 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/config.ts: -------------------------------------------------------------------------------- 1 | import { Astor } from "./plugins/astilectron"; 2 | import Swal from 'sweetalert2' 3 | export const bootstrap: any = require("@/bootstrap/js/bootstrap.min.js"); // eslint-disable-line 4 | export const SUCCESS = "Success"; 5 | export const ERROR = "Error"; 6 | export var igopherConfig: any; // eslint-disable-line 7 | 8 | export const Toast = Swal.mixin({ 9 | toast: true, 10 | position: 'top-right', 11 | iconColor: 'white', 12 | customClass: { 13 | popup: 'colored-toast' 14 | }, 15 | showConfirmButton: false, 16 | showCloseButton: true, 17 | timer: 3000, 18 | timerProgressBar: true 19 | }); 20 | 21 | // Parse JSON Array to JSON Object 22 | export function serialize(data: any) { 23 | const obj: any = {}; 24 | for (const [key, value] of data) { 25 | if (obj[key] !== undefined) { 26 | if (!Array.isArray(obj[key])) { 27 | obj[key] = [obj[key]]; 28 | } 29 | obj[key].push(value); 30 | } else { 31 | obj[key] = value; 32 | } 33 | } 34 | return obj; 35 | } 36 | 37 | // Wait for the DOM to be fully loaded 38 | export const ready = (callback: any) => { 39 | if (document.readyState != "loading") callback(); 40 | else document.addEventListener("DOMContentLoaded", callback); 41 | }; 42 | 43 | export function getIgopherConfig(astor: Astor, callback?: () => void): void { 44 | // Get actual IGopher configuration to fill inputs 45 | astor.trigger("getConfig", {}, function(message: any) { 46 | if (message.status === SUCCESS) { 47 | igopherConfig = JSON.parse(message.msg); 48 | if (callback !== undefined) 49 | callback(); 50 | } else { 51 | Toast.fire({ 52 | icon: 'error', 53 | title: 'Error', 54 | }); 55 | } 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import astor from "@/plugins/astilectron"; 5 | import titleMixin from "@/mixins/titleMixin"; 6 | 7 | import mitt, { Emitter } from 'mitt'; 8 | const emitter: Emitter = mitt(); 9 | export default emitter; 10 | 11 | import "@/bootstrap/css/bootstrap.min.css"; 12 | import "@/bootstrap/js/bootstrap.min.js"; 13 | 14 | const app = createApp(App); 15 | app 16 | .provide('emitter', emitter) 17 | .use(astor, { 18 | debug: true, 19 | emitter: emitter 20 | }) 21 | .mixin(titleMixin) 22 | .use(router) 23 | .mount("#app"); 24 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/mixins/titleMixin.ts: -------------------------------------------------------------------------------- 1 | /* Mixin used to dynamically manage page title for each vue views */ 2 | 3 | function getTitle(vm: any) { 4 | const title = vm.$options; 5 | if (title) { 6 | return typeof title === "function" ? title.call(vm) : title; 7 | } 8 | } 9 | 10 | export default { 11 | mounted() { 12 | const title = getTitle(this); 13 | if (title) { 14 | document.title = "IGopher - " + title.title; 15 | } 16 | }, 17 | }; -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/plugins/astilectron.ts: -------------------------------------------------------------------------------- 1 | declare const astilectron: any; 2 | import { Emitter } from 'mitt'; 3 | export class Astor { 4 | skipWait: boolean; 5 | debug: boolean; 6 | isReady: boolean; 7 | emitter: Emitter; 8 | 9 | constructor(debug: boolean, skipWait: boolean, emitter: Emitter) { 10 | this.debug = debug !== undefined ? debug : false; 11 | this.skipWait = skipWait !== undefined ? skipWait : false; 12 | this.emitter = emitter; 13 | this.isReady = false; 14 | } 15 | 16 | init() { 17 | this.log('init'); 18 | this.isReady = false; 19 | 20 | if (this.skipWait) { 21 | this.onAstilectronReady(); 22 | return; 23 | } 24 | 25 | document.addEventListener('astilectron-ready', this.onAstilectronReady.bind(this)); 26 | } 27 | 28 | onAstilectronReady() { 29 | this.log('astilectron is ready'); 30 | astilectron.onMessage(this.onAstilectronMessage.bind(this)); 31 | this.log('removing ready listener'); 32 | document.removeEventListener('astilectron-ready', this.onAstilectronReady.bind(this)); 33 | this.isReady = true; 34 | } 35 | 36 | onIsReady(callback: any) { 37 | const self = this; 38 | const delay = 100; 39 | if (!this.isReady) { 40 | setTimeout( () => { 41 | if (this.isReady) { 42 | self.log('astor is ready'); 43 | callback(); 44 | } else { 45 | self.onIsReady(callback); 46 | } 47 | }, delay); 48 | } else { 49 | this.log('astor is ready'); 50 | callback(); 51 | } 52 | } 53 | 54 | onAstilectronMessage(message: any) { 55 | if (Array.prototype.slice.call(arguments).length == 1) { // eslint-disable-line 56 | if (message) { 57 | this.log('GO -> Vue', message); 58 | this.emit(message.msg, message); 59 | } 60 | } else { 61 | const identifier = message; 62 | message = Array.prototype.slice.call(arguments)[1]; // eslint-disable-line 63 | if (message) { 64 | this.log('GO -> Vue', message); 65 | this.emit(identifier, message); 66 | } 67 | } 68 | } 69 | 70 | trigger(name: string, payload = {}, callback: any = null) { 71 | let logMessage = 'Vue -> GO'; 72 | let identifier = name; 73 | 74 | if (callback !== null) { 75 | logMessage = logMessage + ' (scoped)'; 76 | identifier = identifier + this.getScope(); 77 | } 78 | 79 | this.log(logMessage, {name: name, payload: payload}); 80 | if (callback !== null) { 81 | this.listen(identifier, callback, true); 82 | } 83 | astilectron.sendMessage({msg: name, payload: payload}, this.onAstilectronMessage.bind(this, identifier)); 84 | } 85 | 86 | listen(name: any, callback: any, once = false) { 87 | if (once) { 88 | this.log('listen once', {name: name, callback: callback}); 89 | const wrappedHandler = (evt: any) => { 90 | callback(evt); 91 | this.emitter.off(name, wrappedHandler); 92 | } 93 | this.emitter.on(name, wrappedHandler); 94 | } else { 95 | this.log('listen', {name: name, callback: callback}); 96 | this.emitter.on(name, callback); 97 | } 98 | } 99 | 100 | emit(name: any, payload = {}) { 101 | this.log('EMIT', {name: name, payload: payload}); 102 | this.emitter.emit(name, payload); 103 | } 104 | 105 | remove(name: any, callback: any) { 106 | this.emitter.off(name, callback); 107 | } 108 | 109 | log(message: any, data?: any) { 110 | if (!this.debug) { 111 | return; 112 | } 113 | 114 | if (data) { 115 | console.log('ASTOR | ' + message, data); 116 | } else { 117 | console.log('ASTOR | ' + message); 118 | } 119 | } 120 | 121 | getScope() { 122 | return '#' + Math.random().toString(36).substr(2, 7); 123 | } 124 | } 125 | 126 | export default { 127 | install (Vue: any, options: any) { 128 | const { debug, skipWait, emitter } = options; 129 | const astor: Astor = new Astor(debug, skipWait, emitter) 130 | 131 | Vue.config.globalProperties.$astor = astor; 132 | Vue.config.globalProperties.$astor.debug = debug; 133 | Vue.config.globalProperties.$astor.skipWait = skipWait; 134 | Vue.config.globalProperties.$astor.init(); 135 | 136 | Vue.provide('astor', astor); 137 | } 138 | } -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' 2 | import DmAutomation from '../views/DmAutomation.vue' 3 | import Settings from '../views/Settings.vue' 4 | import Logs from '../views/Logs.vue' 5 | import About from '../views/About.vue' 6 | import NotFound from '../views/NotFound.vue' 7 | 8 | const routes: Array = [ 9 | { 10 | path: '/', 11 | name: 'DmAutomation', 12 | component: DmAutomation 13 | }, 14 | { 15 | path: '/settings', 16 | name: 'Settings', 17 | component: Settings 18 | }, 19 | { 20 | path: '/logs', 21 | name: 'Logs', 22 | component: Logs 23 | }, 24 | { 25 | path: '/about', 26 | name: 'About', 27 | component: About 28 | }, 29 | { 30 | path: "/:pathMatch(.*)*", 31 | name: '404 Not Found', 32 | component: NotFound 33 | } 34 | ] 35 | 36 | const router = createRouter({ 37 | history: createWebHashHistory(), 38 | routes 39 | }) 40 | 41 | export default router 42 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/theme.ts: -------------------------------------------------------------------------------- 1 | import * as config from "@/config"; 2 | 3 | config.ready(() => { 4 | // Toggle the side navigation 5 | const sidebar = document.querySelector(".sidebar"); 6 | const sidebarToggles = document.querySelectorAll( 7 | "#sidebarToggle, #sidebarToggleTop" 8 | ); 9 | 10 | if (sidebar) { 11 | const collapseEl = sidebar.querySelector(".collapse"); 12 | const collapseElementList = [].slice.call( 13 | document.querySelectorAll(".sidebar .collapse") 14 | ); 15 | const sidebarCollapseList = collapseElementList.map(function(collapseEl) { 16 | return new config.bootstrap.Collapse(collapseEl, { toggle: false }); 17 | }); 18 | 19 | for (const toggle of sidebarToggles) { 20 | // Toggle the side navigation 21 | toggle.addEventListener("click", function(e) { 22 | document.body.classList.toggle("sidebar-toggled"); 23 | sidebar.classList.toggle("toggled"); 24 | 25 | if (sidebar.classList.contains("toggled")) { 26 | for (const bsCollapse of sidebarCollapseList) { 27 | bsCollapse.hide(); 28 | } 29 | } 30 | }); 31 | } 32 | 33 | // Close any open menu accordions when window is resized below 768px 34 | window.addEventListener("resize", function() { 35 | const vw = Math.max( 36 | document.documentElement.clientWidth || 0, 37 | window.innerWidth || 0 38 | ); 39 | if (vw < 768) { 40 | for (const bsCollapse of sidebarCollapseList) { 41 | bsCollapse.hide(); 42 | } 43 | } 44 | }); 45 | } 46 | 47 | // Prevent the content wrapper from scrolling when the fixed side navigation hovered over 48 | const fixedNav = document.querySelector("body.fixed-nav .sidebar"); 49 | if (fixedNav) { 50 | fixedNav.addEventListener("mousewheel DOMMouseScroll wheel", function(e) { 51 | const vw = Math.max( 52 | document.documentElement.clientWidth || 0, 53 | window.innerWidth || 0 54 | ); 55 | if (vw > 768) { 56 | // let delta = e.wheelDelta || -e.detail; 57 | // document.body.scrollTop += (delta < 0 ? 1 : -1) * 30; 58 | // e.preventDefault(); 59 | } 60 | }); 61 | } 62 | 63 | // Scroll to top button appear 64 | const scrollToTop = document.querySelector(".back-to-top") as HTMLElement; 65 | window.addEventListener("scroll", function() { 66 | const scrollDistance = window.pageYOffset; 67 | 68 | // Check if user is scrolling up 69 | if (scrollToTop != null) { 70 | if (scrollDistance > 100) { 71 | scrollToTop.style.display = "block"; 72 | } else { 73 | scrollToTop.style.display = "none"; 74 | } 75 | } 76 | }); 77 | 78 | // Scroll to top button callback 79 | function backToTop() { 80 | const rootElement = document.documentElement 81 | rootElement.scrollTo({ 82 | top: 0, 83 | behavior: "smooth" 84 | }) 85 | } 86 | scrollToTop.addEventListener("click", backToTop); 87 | 88 | }); 89 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/views/DmAutomation.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/views/Logs.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | 24 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strictNullChecks": false, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "src/bootstrap/**", 40 | "node_modules", 41 | "public" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /resources/static/vue-igopher/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: './' 3 | }; -------------------------------------------------------------------------------- /scripts/bundle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script used to build GUI version and bundle executables for Windows/Linux/MacOS 3 | # Require Go and Node/Npm installed 4 | # You must locate your terminal into the scripts sub-folder before running this script 5 | 6 | # Check if pwd is located inside the "scripts" sub-folder 7 | if [ $(basename "$PWD") != "scripts" ]; then 8 | cd "scripts" 9 | if [ $(basename "$PWD") != "scripts" ]; then 10 | echo "Invalid current directory! Please cd to the IGopher scripts sub-directory and re-run this script." 11 | exit 1 12 | fi 13 | fi 14 | 15 | cd "../resources/static/vue-igopher" 16 | 17 | # Install node dependencies and build vue-igopher 18 | npm install 19 | npm run build 20 | 21 | cd "../../../cmd/igopher/gui-bundler" 22 | 23 | # Download and install go-astilectron-bundler 24 | go get github.com/asticode/go-astilectron-bundler/... 25 | go install github.com/asticode/go-astilectron-bundler/astilectron-bundler 26 | 27 | # Install dependencies 28 | go get ../../../... 29 | 30 | # Rename default bind.go to tmp file 31 | mv bind.go bind.go.tmp 32 | 33 | # Execute astilectron-bundler 34 | astilectron-bundler -c bundler.json 35 | 36 | # Delete generated files and restore bind.go 37 | rm bind_*.go windows.syso 38 | mv bind.go.tmp bind.go 39 | echo "Done. Executables are located in 'cmd/igopher/gui-bundler/output/' folder" -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script used to build GUI and TUI version for Windows/Linux/MacOS 3 | 4 | # Check if pwd is located inside IGopher root directory 5 | if [ $(basename "$PWD") != "igopher" ]; then 6 | cd "${0%/*}/.." 7 | if [ $(basename "$PWD") != "gui-bundler" ]; then 8 | echo "Invalid current directory! Please cd to the IGopher directory and re-run this script." 9 | exit 1 10 | fi 11 | fi 12 | 13 | # Call bundle.sh script to build GUI executables 14 | ./scripts/bundle.sh 15 | 16 | # Build TUI executables fot all OS 17 | env GOOS=linux GOARCH=amd64 go build -o ./bin/IGopherTUI-linux-amd64 ./cmd/igopher/tui 18 | env GOOS=windows GOARCH=amd64 go build -o ./bin/IGopherTUI-windows-amd64.exe ./cmd/igopher/tui 19 | env GOOS=darwin GOARCH=amd64 go build -o ./bin/IGopherTUI-macOS-amd64 ./cmd/igopher/tui 20 | 21 | echo "Done. TUI executables are located in 'bin/' folder" 22 | --------------------------------------------------------------------------------