Skip to content
Open

Merge #450

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Repository Guidelines

## Project Structure & Module Organization
- Core code lives in `GramAddict/core/` (session flow, navigation, utils) and `GramAddict/plugins/` (jobs for hashtags, reels, followers, Telegram reports). Entry points are `run.py` and `python -m GramAddict`.
- Configs sit in `config-examples/` and `accounts/<user>/`; assets in `res/`; extras in `extra/`; tests in `test/` with fixtures under `test/mock_data/` and text samples in `test/txt/`.
- Dependencies are in `requirements.txt`; optional dev extras sit under `[project.optional-dependencies].dev` in `pyproject.toml`. Local adb binaries can live in `platform-tools/` when installed via `scripts/setup-adb.sh`.

## Build, Test, and Development Commands
- Create a venv and install: `python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt` (add `pip install -e .[dev]` for lint/tests).
- Install bundled adb without touching the system: `./scripts/setup-adb.sh` then `export ADB_PATH=$PWD/platform-tools/platform-tools/adb`.
- Run the bot: `python -m GramAddict run --config accounts/<user>/config.yml`; initialize configs with `python -m GramAddict init <user>`; capture a UI dump with `python -m GramAddict dump --device <serial>`.
- Quality checks: `black .`, `pyflakes .`, and `python -m pytest` when dev extras are installed.

## Coding Style & Naming Conventions
- Black defaults (4-space indent, ~88-char lines); snake_case for functions/modules, PascalCase for classes, descriptive plugin filenames.
- Keep side effects in CLI layers; helpers should be deterministic and log via `GramAddict.core.log`.

## Testing Guidelines
- Use pytest with `test_*` names; fixtures live in `test/mock_data/`. Mock filesystem/adb to keep tests device-free.
- Add coverage for plugins and data transforms when behavior changes; keep samples lightweight.

## Commit & Pull Request Guidelines
- Commit messages: present-tense, imperative, ~72 chars (e.g., `fix: handle search reels`). Keep secrets and account configs out of VCS—use `config-examples/` as templates.
- PRs: include a short summary, tests run, linked issues, and logs/screenshots for UI changes.

## Feature Flags & Config Tips
- Prefer config over CLI. Key toggles: `watch-reels` (also used for search-opened reels), `reels-like-percentage`, `reels-watch-time`; startup randomness via `notifications-percentage`; comment likes via `like-comments-percentage`, `like-comments-per-post`, `comment-like-sort`; allow new IG builds with `allow-untested-ig-version: true`.
- Prefer USB connections; specify devices by serial. Set `ADB_PATH` if using the bundled adb; Wi-Fi adb is only used when an explicit host:port is provided.
2 changes: 1 addition & 1 deletion GramAddict/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Human-like Instagram bot powered by UIAutomator2"""

__version__ = "3.2.12"
__tested_ig_version__ = "300.0.0.29.110"
__tested_ig_version__ = "410.1.0.63.71"

from GramAddict.core.bot_flow import start_bot

Expand Down
64 changes: 58 additions & 6 deletions GramAddict/core/bot_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import warnings
import random
from datetime import datetime, timedelta
from time import sleep
Expand All @@ -7,7 +8,12 @@

from GramAddict import __tested_ig_version__
from GramAddict.core.config import Config
from GramAddict.core.device_facade import create_device, get_device_info
from GramAddict.core.device_facade import (
Direction,
create_device,
get_device_info,
load_config as load_device_facade,
)
from GramAddict.core.filter import Filter
from GramAddict.core.filter import load_config as load_filter
from GramAddict.core.interaction import load_config as load_interaction
Expand All @@ -34,6 +40,8 @@
get_value,
head_up_notifications,
kill_atx_agent,
random_choice,
random_choice,
)
from GramAddict.core.utils import load_config as load_utils
from GramAddict.core.utils import (
Expand All @@ -53,6 +61,10 @@


def start_bot(**kwargs):
# Silence noisy third-party warnings (cached_property asyncio.iscoroutinefunction)
warnings.filterwarnings(
"ignore", category=DeprecationWarning, message=".*asyncio.iscoroutinefunction.*"
)
# Logging initialization
logger = logging.getLogger(__name__)

Expand All @@ -70,15 +82,16 @@ def start_bot(**kwargs):
# Config-example hint
config_examples()

# Check for updates
check_if_updated()
# Check for updates (disabled for forked/offline use)
# check_if_updated()

# Move username folders to a main directory -> accounts
if "--move-folders-in-accounts" in configs.args:
move_usernames_to_accounts()

# Global Variables
sessions = PersistentList("sessions", SessionStateEncoder)
configs.sessions = sessions

# Load Config
configs.load_plugins()
Expand All @@ -90,6 +103,7 @@ def start_bot(**kwargs):
load_interaction(configs)
load_utils(configs)
load_views(configs)
load_device_facade(configs)

if not configs.args or not check_adb_connection():
return
Expand All @@ -108,7 +122,7 @@ def start_bot(**kwargs):

# init
analytics_at_end = False
telegram_reports_at_end = False
telegram_reports_at_end = bool(configs.args.telegram_reports)
followers_now = None
following_now = None

Expand All @@ -124,6 +138,7 @@ def start_bot(**kwargs):
restart_atx_agent(device)
get_device_info(device)
session_state = SessionState(configs)
configs.session_state = session_state
session_state.set_limits_session()
sessions.append(session_state)
check_screen_timeout()
Expand Down Expand Up @@ -180,6 +195,24 @@ def start_bot(**kwargs):
account_view = AccountView(device)
tab_bar_view = TabBarView(device)
try:
# Randomly decide whether to peek at notifications first (from home)
notif_pct = get_value(
configs.args.notifications_percentage, None, 50
)
if notif_pct and random_choice(int(notif_pct)):
logger.info("Opening notifications tab before refreshing profile.")
tab_bar_view.navigateToActivity()
sleep(random.uniform(0.8, 1.2))
logger.debug("Pull-to-refresh notifications.")
UniversalActions(device)._swipe_points(
Direction.DOWN, start_point_y=400, delta_y=650
)
sleep(random.uniform(0.8, 1.6))
logger.debug("Exit notifications via back to restore tab bar.")
device.back()
sleep(random.uniform(0.5, 1.0))
tab_bar_view.navigateToHome()

account_view.navigate_to_main_account()
check_if_english(device)
if configs.args.username is not None:
Expand All @@ -203,6 +236,15 @@ def start_bot(**kwargs):
save_crash(device)
break

if (
session_state.my_username is None
and configs.args.username is not None
):
logger.warning(
"Username not detected from UI; falling back to config username."
)
session_state.my_username = configs.args.username

if (
session_state.my_username is None
or session_state.my_posts_count is None
Expand Down Expand Up @@ -244,14 +286,14 @@ def start_bot(**kwargs):
analytics_at_end = True
if "telegram-reports" in jobs_list:
jobs_list.remove("telegram-reports")
if configs.args.telegram_reports:
telegram_reports_at_end = True
print_limits = True
unfollow_jobs = [x for x in jobs_list if "unfollow" in x]
logger.info(
f"There is/are {len(jobs_list)-len(unfollow_jobs)} active-job(s) and {len(unfollow_jobs)} unfollow-job(s) scheduled for this session."
)
storage = Storage(session_state.my_username)
# Expose storage so view helpers can reference interacted users when needed
configs.storage = storage
filters = Filter(storage)
show_ending_conditions()
if not configs.args.debug:
Expand Down Expand Up @@ -298,9 +340,11 @@ def start_bot(**kwargs):
f"Current unfollow-job: {plugin}",
extra={"color": f"{Style.BRIGHT}{Fore.BLUE}"},
)
session_state.start_job(plugin)
configs.actions[plugin].run(
device, configs, storage, sessions, filters, plugin
)
session_state.end_job()
unfollow_jobs.remove(plugin)
print_limits = True
else:
Expand All @@ -326,9 +370,11 @@ def start_bot(**kwargs):
logger.warning(
"You're in scraping mode! That means you're only collection data without interacting!"
)
session_state.start_job(plugin)
configs.actions[plugin].run(
device, configs, storage, sessions, filters, plugin
)
session_state.end_job()
print_limits = True

# save the session in sessions.json
Expand Down Expand Up @@ -386,6 +432,8 @@ def start_bot(**kwargs):
followers_now,
following_now,
time_left,
session_state=session_state,
sessions=sessions,
)
logger.info(
f'Next session will start at: {(datetime.now() + timedelta(seconds=time_left)).strftime("%H:%M:%S (%Y/%m/%d)")}.'
Expand All @@ -406,6 +454,8 @@ def start_bot(**kwargs):
followers_now,
following_now,
time_left.total_seconds(),
session_state=session_state,
sessions=sessions,
)
wait_for_next_session(
time_left,
Expand All @@ -420,6 +470,8 @@ def start_bot(**kwargs):
telegram_reports_at_end,
followers_now,
following_now,
session_state=session_state,
sessions=sessions,
)
print_full_report(sessions, configs.args.scrape_to_file)
ask_for_a_donation()
59 changes: 32 additions & 27 deletions GramAddict/core/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
check_if_crash_popup_is_there,
close_instagram,
open_instagram,
print_telegram_reports,
random_sleep,
save_crash,
RandomStop,
stop_bot,
)
from GramAddict.core.views import TabBarView
Expand All @@ -30,34 +32,19 @@ def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except KeyboardInterrupt:
try:
# Catch Ctrl-C and ask if user wants to pause execution
logger.info(
"CTRL-C detected . . .",
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"},
)
logger.info(
f"-------- PAUSED: {datetime.now().strftime('%H:%M:%S')} --------",
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"},
)
logger.info(
"NOTE: This is a rudimentary pause. It will restart the action, while retaining session data.",
extra={"color": Style.BRIGHT},
)
logger.info(
"Press RETURN to resume or CTRL-C again to Quit: ",
extra={"color": Style.BRIGHT},
)

input("")
# Respect immediate exit on Ctrl-C
logger.info(
"CTRL-C detected, stopping the bot.",
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"},
)
stop_bot(device, sessions, session_state)

logger.info(
f"-------- RESUMING: {datetime.now().strftime('%H:%M:%S')} --------",
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"},
)
TabBarView(device).navigateToProfile()
except KeyboardInterrupt:
stop_bot(device, sessions, session_state)
except RandomStop:
logger.info(
"Random stop triggered, stopping the bot.",
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"},
)
stop_bot(device, sessions, session_state)

except DeviceFacade.AppHasCrashed:
logger.warning("App has crashed / has been closed!")
Expand Down Expand Up @@ -97,6 +84,15 @@ def wrapper(*args, **kwargs):
close_instagram(device)
print_full_report(sessions, configs.args.scrape_to_file)
sessions.persist(directory=session_state.my_username)
if getattr(configs.args, "telegram_reports", False):
print_telegram_reports(
configs,
True,
None,
None,
session_state=session_state,
sessions=sessions,
)
raise e from e

return wrapper
Expand Down Expand Up @@ -134,5 +130,14 @@ def restart(
if not open_instagram(device):
print_full_report(sessions, configs.args.scrape_to_file)
sessions.persist(directory=session_state.my_username)
if getattr(configs.args, "telegram_reports", False):
print_telegram_reports(
configs,
True,
None,
None,
session_state=session_state,
sessions=sessions,
)
sys.exit(2)
TabBarView(device).navigateToProfile()
Loading