Hardening autonomous navigation: Implemented Modal Guards, Transient Drift Protection (WhatsApp fix), and Aggressive Recovery paths. Cleaned up diagnostic artifacts.
This commit is contained in:
@@ -59,6 +59,12 @@ def start_bot(**kwargs):
|
||||
# Check for direct execution modes that bypass normal bot state
|
||||
configs.parse_args()
|
||||
|
||||
try:
|
||||
from GramAddict.core.llm_provider import prewarm_ollama_models
|
||||
prewarm_ollama_models(configs)
|
||||
except Exception as e:
|
||||
logger.debug(f"Prewarm failed: {e}")
|
||||
|
||||
if getattr(configs.args, "capture_e2e_dumps", False):
|
||||
device = create_device(configs.device_id, configs.app_id, configs.args)
|
||||
from GramAddict.core.dump_capturer import capture_all
|
||||
@@ -186,12 +192,12 @@ def start_bot(**kwargs):
|
||||
if success:
|
||||
if current_target == "ExploreFeed":
|
||||
logger.info("📱 Opening first explore item from the grid...")
|
||||
nav_graph._execute_transition("tap_explore_grid_item", zero_engine)
|
||||
nav_graph._execute_transition("tap_explore_grid_item")
|
||||
# Wait for post to actually load (poll for feed markers)
|
||||
_wait_for_post_loaded(device, timeout=5)
|
||||
elif current_target == "StoriesFeed":
|
||||
logger.info("📱 Locating story tray on HomeFeed...")
|
||||
nav_graph._execute_transition("tap_story_tray_item", zero_engine)
|
||||
nav_graph._execute_transition("tap_story_tray_item")
|
||||
_wait_for_post_loaded(device, timeout=5)
|
||||
|
||||
if current_target == "StoriesFeed":
|
||||
@@ -256,6 +262,9 @@ FEED_MARKERS = [
|
||||
"row_feed_profile_header",
|
||||
"row_feed_photo_imageview",
|
||||
"clips_media_component",
|
||||
"clips_video_container",
|
||||
"clips_viewer_container",
|
||||
"clips_linear_layout_container"
|
||||
]
|
||||
|
||||
|
||||
@@ -266,7 +275,7 @@ def _wait_for_post_loaded(device, timeout=5):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
xml = device.deviceV2.dump_hierarchy()
|
||||
xml = device.dump_hierarchy()
|
||||
if any(marker in xml for marker in FEED_MARKERS):
|
||||
logger.debug("📱 Post loaded successfully.")
|
||||
return True
|
||||
@@ -424,14 +433,25 @@ def _interact_with_carousel(device, configs, sleep_mod, logger):
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
|
||||
# Curiosity Peak: One slide in the carousel gets extra attention
|
||||
curiosity_slide = random.randint(0, count - 1) if count > 0 else 0
|
||||
|
||||
for i in range(count):
|
||||
# Normal transition wait
|
||||
sleep(random.uniform(1.5, 3.5) * sleep_mod)
|
||||
|
||||
# ── Curiosity Dwell ──
|
||||
if i == curiosity_slide:
|
||||
dwell = random.uniform(3.0, 7.0)
|
||||
logger.debug(f"📸 [Carousel] Curiosity Peak hit on slide {i+1}. Gazing for {dwell:.1f}s...")
|
||||
sleep(dwell * sleep_mod)
|
||||
|
||||
# Horizontal swipe inside the post bounds (approx middle): Right to left
|
||||
_humanized_horizontal_swipe(device, start_x=w*0.8, end_x=w*0.2, y=h*0.5, duration_ms=250)
|
||||
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
|
||||
def _interact_with_profile(device, configs, username, session_state, sleep_mod, logger):
|
||||
print("HELLO IM NOT MOCKED!")
|
||||
"""Deep interaction on a profile: Stories, Grid Likes, Follows"""
|
||||
import random
|
||||
|
||||
@@ -442,11 +462,26 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
|
||||
xml_check = device.dump_hierarchy()
|
||||
if not isinstance(xml_check, str):
|
||||
return
|
||||
|
||||
xml_check_lower = xml_check.lower()
|
||||
|
||||
# ── 1. Profile Guards (Private / Empty) ──
|
||||
if "this account is private" in xml_check_lower or "konto ist privat" in xml_check_lower:
|
||||
logger.info(f"🔒 [Profile Guard] @{username} is private. Aborting deep interaction.", extra={"color": f"{Fore.YELLOW}"})
|
||||
return
|
||||
|
||||
if "no posts yet" in xml_check_lower or "noch keine beiträge" in xml_check_lower:
|
||||
logger.info(f"📭 [Profile Guard] @{username} has no posts. Aborting deep interaction.", extra={"color": f"{Fore.YELLOW}"})
|
||||
return
|
||||
|
||||
# Profile Scraping (Phase 11: Data Extraction)
|
||||
if getattr(configs.args, "scrape_profiles", False):
|
||||
try:
|
||||
logger.info(f"📊 [Scraping] Extracting metadata for @{username}...", extra={"color": f"{Fore.CYAN}"})
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
crm = cognitive_stack.get("crm") if 'cognitive_stack' in locals() else None
|
||||
|
||||
@@ -483,7 +518,9 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(device)
|
||||
|
||||
if nav_graph._execute_transition("tap_story_tray_item"):
|
||||
xml_dump = device.dump_hierarchy()
|
||||
has_story = "reel_ring" in xml_dump or "'s unseen story" in xml_dump.lower() or "has a new story" in xml_dump.lower() or "story von" in xml_dump.lower()
|
||||
if has_story and nav_graph._execute_transition("tap_story_tray_item"):
|
||||
logger.info(f"📸 [Story] Viewing @{username}'s story ({count} times)...")
|
||||
for i in range(count):
|
||||
sleep(random.uniform(2.0, 5.0) * sleep_mod)
|
||||
@@ -530,13 +567,46 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
logger.info(f"❤️ [Deep Interaction] Opening grid to drop {count} likes on @{username}...")
|
||||
|
||||
for i in range(count):
|
||||
_humanized_click(device, w // 2, h // 2, double=True, sleep_mod=sleep_mod)
|
||||
session_state.totalLikes += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count}")
|
||||
xml_dump = device.dump_hierarchy()
|
||||
if not isinstance(xml_dump, str):
|
||||
xml_dump = ""
|
||||
xml_dump_lower = xml_dump.lower()
|
||||
|
||||
is_reel = "reel_viewer" in xml_dump_lower or "clips_viewer" in xml_dump_lower
|
||||
is_liked = "gefällt mir nicht mehr" in xml_dump_lower or "unlike" in xml_dump_lower or 'content-desc="liked"' in xml_dump_lower
|
||||
|
||||
# Uset Double-Tap ~40% of the time, only on standard images
|
||||
use_double_tap = random.random() < 0.4 and not is_reel
|
||||
|
||||
if use_double_tap:
|
||||
if is_liked:
|
||||
logger.debug(f"Skipped liking grid post {i+1}/{count} (already liked)")
|
||||
else:
|
||||
offset_x = random.randint(int(w * 0.2), int(w * 0.8))
|
||||
offset_y = random.randint(int(h * 0.3), int(h * 0.7))
|
||||
logger.info(f"❤️ [Interaction] Double-Tapping organically at ({offset_x}, {offset_y})")
|
||||
_humanized_click(device, offset_x, offset_y, double=True, sleep_mod=sleep_mod)
|
||||
session_state.totalLikes += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Double-Tap")
|
||||
else:
|
||||
if nav_graph._execute_transition("tap_like_button"):
|
||||
session_state.totalLikes += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Heart Button")
|
||||
else:
|
||||
logger.debug(f"Skipped liking grid post {i+1}/{count} (already liked or failed to find button)")
|
||||
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
start_scroll_y, end_scroll_y = int(h * 0.7) + random.randint(-20, 20), int(h * 0.2) + random.randint(-40, 40)
|
||||
scroll_x = w // 2 + random.randint(-30, 30)
|
||||
device.deviceV2.shell(f"input swipe {scroll_x} {start_scroll_y} {scroll_x + random.randint(-15,15)} {end_scroll_y} {random.randint(250, 400)}")
|
||||
|
||||
is_reel = "reel_viewer" in xml_dump_lower or "clips_viewer" in xml_dump_lower
|
||||
|
||||
if is_reel:
|
||||
# Full screen swipe for Reels (using humanized fast fling)
|
||||
logger.debug("🎬 Detected Reel. Swiping full-screen up.")
|
||||
_humanized_scroll(device, is_skip=True)
|
||||
else:
|
||||
# Partial screen swipe for standard posts
|
||||
_humanized_scroll(device, is_skip=False)
|
||||
|
||||
sleep(random.uniform(1.5, 3.0) * sleep_mod)
|
||||
|
||||
device.deviceV2.press("back")
|
||||
@@ -564,7 +634,7 @@ def _align_active_post(device):
|
||||
while not aligned and attempts < max_attempts:
|
||||
attempts += 1
|
||||
try:
|
||||
xml = device.deviceV2.dump_hierarchy()
|
||||
xml = device.dump_hierarchy()
|
||||
clean_xml = re.sub(r'<\?xml.*?\?>', '', xml).strip()
|
||||
root = ET.fromstring(clean_xml)
|
||||
|
||||
@@ -648,7 +718,12 @@ def _detect_ad_structural(context_xml: str) -> bool:
|
||||
"com.instagram.android:id/universal_cta_description_layout",
|
||||
"com.instagram.android:id/universal_cta_text",
|
||||
}
|
||||
AD_CTA_WORDS = {"install", "learn more", "shop now", "sign up", "mehr dazu", "jetzt einkaufen", "installieren", "registrieren", "anmelden", "download", "herunterladen", "get offer", "abonnieren", "subscribe"}
|
||||
AD_CTA_WORDS = {
|
||||
"install", "learn more", "shop now", "sign up", "mehr dazu", "jetzt einkaufen",
|
||||
"installieren", "registrieren", "anmelden", "download", "herunterladen",
|
||||
"get offer", "abonnieren", "subscribe", "whatsapp", "nachricht senden",
|
||||
"send message", "jetzt anrufen", "call now", "contact us", "kontaktieren"
|
||||
}
|
||||
|
||||
try:
|
||||
root = ET.fromstring(context_xml)
|
||||
@@ -699,7 +774,7 @@ def _extract_post_content(context_xml: str) -> dict:
|
||||
desc = node.attrib.get("content-desc", "").strip()
|
||||
|
||||
# Username from the post header (ignore commenters/composers)
|
||||
if "row_feed_photo_profile_name" in res_id and text:
|
||||
if ("row_feed_photo_profile_name" in res_id or "clips_author_username" in res_id) and text:
|
||||
if "comment" not in res_id and "composer" not in res_id:
|
||||
# Prioritize the FIRST valid username found (usually the header)
|
||||
if not result["username"]:
|
||||
@@ -767,7 +842,7 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
if not xml_dump:
|
||||
logger.warning("Failed to dump UI hierarchy in StoriesFeed.")
|
||||
return "CONTEXT_LOST"
|
||||
@@ -858,26 +933,42 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
|
||||
# ── Boredom ──
|
||||
if random.random() < 0.03 and not is_reels:
|
||||
logger.info("🥱 [Boredom] Checking something else (Notifications/DMs) for a second...")
|
||||
|
||||
# Use NavGraph transitions instead of raw selectors
|
||||
if random.random() < 0.5:
|
||||
# Try to visit Notifications
|
||||
nav_graph._execute_transition("tap_newsfeed_tab", zero_engine)
|
||||
else:
|
||||
# Try to visit DMs
|
||||
nav_graph._execute_transition("tap_message_icon", zero_engine)
|
||||
if job_target in ["feed", "home", "homefeed"]:
|
||||
logger.info("🥱 [Boredom] Checking something else (Notifications/DMs) for a second...")
|
||||
|
||||
sleep(random.uniform(3.0, 6.0) * sleep_mod)
|
||||
# Use NavGraph transitions instead of raw selectors
|
||||
if random.random() < 0.5:
|
||||
# Try to visit Notifications
|
||||
nav_graph._execute_transition("tap_newsfeed_tab")
|
||||
else:
|
||||
# Try to visit DMs
|
||||
nav_graph._execute_transition("tap_message_icon")
|
||||
|
||||
sleep(random.uniform(3.0, 6.0) * sleep_mod)
|
||||
|
||||
# Return to feed natively through robust navigation
|
||||
nav_graph.navigate_to("HomeFeed", zero_engine)
|
||||
sleep(random.uniform(1.0, 2.5) * sleep_mod)
|
||||
|
||||
# Return to feed natively through robust navigation
|
||||
nav_graph.navigate_to("HomeFeed", zero_engine)
|
||||
sleep(random.uniform(1.0, 2.5) * sleep_mod)
|
||||
|
||||
context_xml = device.deviceV2.dump_hierarchy()
|
||||
context_xml = device.dump_hierarchy()
|
||||
if cognitive_stack.get("radome"):
|
||||
context_xml = cognitive_stack.get("radome").sanitize_xml(context_xml)
|
||||
|
||||
# ── PRE-EMPTIVE AD SKIP (Fast Path) ──
|
||||
if _detect_ad_structural(context_xml):
|
||||
consecutive_ads += 1
|
||||
if consecutive_ads >= 3:
|
||||
logger.warning("📺 [Anti-Stuck] Stuck on ad! Executing aggressive skip.", extra={"color": f"{Fore.RED}"})
|
||||
_humanized_scroll(device, is_skip=True)
|
||||
sleep(2.0)
|
||||
else:
|
||||
logger.info("📺 fast-skipping ad (no AI needed)...")
|
||||
_humanized_scroll(device, is_skip=True)
|
||||
sleep(random.uniform(0.5, 1.0) * sleep_mod)
|
||||
continue
|
||||
|
||||
consecutive_ads = 0
|
||||
|
||||
# ── Zero-Node Recovery (Graceful Degradation) ──
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
interactive_nodes = telepathic._extract_semantic_nodes(context_xml)
|
||||
@@ -923,7 +1014,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
sleep(2.5)
|
||||
|
||||
# Verification: Check if markers are now visible
|
||||
post_recovery_xml = device.deviceV2.dump_hierarchy()
|
||||
post_recovery_xml = device.dump_hierarchy()
|
||||
if any(marker in post_recovery_xml for marker in FEED_MARKERS):
|
||||
logger.info("✅ [Recovery] Obstacle cleared successfully. Learning this button works.")
|
||||
telepathic.confirm_click("Dismiss Obstacle/Modal")
|
||||
@@ -956,28 +1047,10 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
# Fixes the issue where UI gets stuck halfway between two posts.
|
||||
if _align_active_post(device):
|
||||
# Update context_xml because the screen just shifted
|
||||
context_xml = device.deviceV2.dump_hierarchy()
|
||||
context_xml = device.dump_hierarchy()
|
||||
if cognitive_stack.get("radome"):
|
||||
context_xml = cognitive_stack.get("radome").sanitize_xml(context_xml)
|
||||
|
||||
# ── Structural Ad Detection (Language-Agnostic) ──
|
||||
if _detect_ad_structural(context_xml):
|
||||
consecutive_ads += 1
|
||||
if consecutive_ads >= 3:
|
||||
logger.warning("📺 [Anti-Stuck] Stuck on ad! Executing aggressive mechanical drag.", extra={"color": f"{Fore.RED}"})
|
||||
# Fast drag from bottom to top (~0.15s) to guarantee Native Android Fling event for Reels
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
device.deviceV2.shell(f"input swipe {w // 2} {int(h * 0.8)} {w // 2} {int(h * 0.2)} 150")
|
||||
sleep(2.0)
|
||||
else:
|
||||
logger.info("📺 skipping ad (structural match)...")
|
||||
_humanized_scroll(device, is_skip=True)
|
||||
sleep(random.uniform(0.5, 1.5) * sleep_mod)
|
||||
continue
|
||||
|
||||
consecutive_ads = 0
|
||||
|
||||
# ── Content Extraction (The Bot's Eyes) ──
|
||||
post_data = _extract_post_content(context_xml)
|
||||
|
||||
@@ -1014,7 +1087,14 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
})
|
||||
|
||||
# ── Human-like Selective Skipping (Anti-Bot Drip) ──
|
||||
skip_prob = 0.85 if res_score < 0.35 else 0.45 if res_score < 0.70 else 0.10
|
||||
base_skip_prob = 0.85 if res_score < 0.35 else 0.45 if res_score < 0.70 else 0.10
|
||||
|
||||
# User defined interact_percentage modulates the skip rate.
|
||||
# Default is 80%, so factor = 1.0. If 100%, factor = 0.0 (never skip).
|
||||
interact_pct_val = float(getattr(configs.args, "interact_percentage", 80)) / 100.0
|
||||
skip_factor = max(0.0, (1.0 - interact_pct_val) * 5.0)
|
||||
skip_prob = base_skip_prob * skip_factor
|
||||
|
||||
if random.random() < skip_prob:
|
||||
logger.info(f"⏭️ [Resonance Skip] Human-like selective engagement ({skip_prob*100:.0f}% chance). Skipping post.")
|
||||
session_outcomes.append({
|
||||
@@ -1029,7 +1109,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
# ── The Rabbit Hole (Deep Dive into high-resonance profiles) ──
|
||||
if res_score >= 0.9 and random.random() < 0.4:
|
||||
logger.info("💥 [Rabbit Hole] Extreme resonance! Sidetracking into user profile...", extra={"color": f"{Fore.MAGENTA}"})
|
||||
if nav_graph._execute_transition("tap_post_username", zero_engine) is True:
|
||||
if nav_graph._execute_transition("tap_post_username") is True:
|
||||
sleep(random.uniform(1.2, 2.5) * sleep_mod)
|
||||
_humanized_scroll(device, is_skip=True)
|
||||
sleep(random.uniform(0.5, 1.5) * sleep_mod)
|
||||
@@ -1044,6 +1124,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
darwin.execute_proof_of_resonance(
|
||||
device,
|
||||
res_score,
|
||||
text_length=len(post_data.get("description", "")),
|
||||
nav_graph=nav_graph,
|
||||
zero_engine=zero_engine,
|
||||
configs=configs,
|
||||
@@ -1063,11 +1144,17 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
profile_context = ""
|
||||
# ── Profile Learning (Before heavy engagement) ──
|
||||
target_user = post_data.get('username', 'target')
|
||||
if res_score >= 0.8:
|
||||
|
||||
# Pull follow chance early to see if the user explicitly wants high follow rates
|
||||
follow_chance_val = float(getattr(configs.args, "follow_percentage", 0)) / 100.0
|
||||
|
||||
# If the user sets follow > 0, we must visit the profile to have a chance to follow.
|
||||
# Otherwise, we rely entirely on the extreme resonance heuristic (> 0.8).
|
||||
if res_score >= 0.8 or (follow_chance_val > 0.0 and random.random() < follow_chance_val):
|
||||
logger.info(f"🕵️♂️ [Profile Learning] Highly resonant post ({res_score:.2f}). Visiting @{target_user}'s profile to learn context...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
# Navigate to profile
|
||||
if nav_graph._execute_transition("tap_post_username", zero_engine) is True:
|
||||
if nav_graph._execute_transition("tap_post_username") is True:
|
||||
sleep(random.uniform(1.2, 2.5) * sleep_mod)
|
||||
|
||||
# Extract context
|
||||
@@ -1075,7 +1162,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
crm = cognitive_stack.get("crm")
|
||||
if telepathic:
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
nodes = telepathic._extract_semantic_nodes(xml_dump)
|
||||
|
||||
texts = []
|
||||
@@ -1107,43 +1194,63 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
if session_state.check_limit(SessionState.Limit.LIKES):
|
||||
likes_chance = 0.0
|
||||
|
||||
if res_score >= 0.35 and random.random() < likes_chance:
|
||||
logger.info("❤️ [Interaction] Liking post...")
|
||||
success = nav_graph._execute_transition("tap_like_button", zero_engine)
|
||||
if success is not True:
|
||||
logger.debug("Telepathic Like failed, falling back to double-tap.")
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
_humanized_click(device, w // 2, h // 2, double=True, sleep_mod=sleep_mod)
|
||||
session_state.totalLikes += 1
|
||||
sleep(random.uniform(1.2, 2.5) * sleep_mod)
|
||||
did_interact = True
|
||||
did_like = True
|
||||
# If user explicitly configures likes_chance > 0, we lower the strict AI resonance requirement
|
||||
needs_like = (likes_chance > 0.0 and random.random() < likes_chance)
|
||||
if (needs_like or res_score >= 0.35):
|
||||
logger.info("❤️ [Interaction] Deciding like method...")
|
||||
|
||||
# Umentscheidung (Change of mind)
|
||||
if random.random() < 0.02:
|
||||
logger.info("🧠 [Umentscheidung] Taking back the like.")
|
||||
sleep(random.uniform(1.0, 3.0))
|
||||
# Press like button again to unlike
|
||||
nav_graph._execute_transition("tap_like_button", zero_engine)
|
||||
session_state.totalLikes -= 1
|
||||
did_like = False
|
||||
did_interact = False
|
||||
xml_check = device.dump_hierarchy()
|
||||
if not isinstance(xml_check, str): xml_check = ""
|
||||
xml_check_lower = xml_check.lower()
|
||||
|
||||
is_reel_feed = "reel_viewer" in xml_check_lower or "clips_viewer" in xml_check_lower
|
||||
is_liked_feed = "gefällt mir nicht mehr" in xml_check_lower or "unlike" in xml_check_lower or 'content-desc="liked"' in xml_check_lower
|
||||
|
||||
use_double_tap = random.random() < 0.4 and not is_reel_feed
|
||||
did_like = False
|
||||
|
||||
if use_double_tap:
|
||||
if is_liked_feed:
|
||||
logger.debug("Telepathic Like failed or post was unlikable (already liked). Skipping increment.")
|
||||
else:
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
offset_x = random.randint(int(w * 0.2), int(w * 0.8))
|
||||
offset_y = random.randint(int(h * 0.3), int(h * 0.7))
|
||||
logger.info(f"❤️ [Interaction] Double-Tapping organically at ({offset_x}, {offset_y})")
|
||||
_humanized_click(device, offset_x, offset_y, double=True, sleep_mod=sleep_mod)
|
||||
session_state.totalLikes += 1
|
||||
sleep(random.uniform(1.2, 2.5) * sleep_mod)
|
||||
did_interact = True
|
||||
did_like = True
|
||||
else:
|
||||
logger.info("❤️ [Interaction] Liking post via Heart Button...")
|
||||
success = nav_graph._execute_transition("tap_like_button")
|
||||
if success:
|
||||
session_state.totalLikes += 1
|
||||
sleep(random.uniform(1.2, 2.5) * sleep_mod)
|
||||
did_interact = True
|
||||
did_like = True
|
||||
else:
|
||||
logger.debug("Telepathic Like failed or post was unlikable. Skipping increment.")
|
||||
|
||||
|
||||
# Comment: requires high resonance alignment
|
||||
comment_chance = float(getattr(configs.args, "comment_percentage", 0)) / 100.0
|
||||
if session_state.check_limit(SessionState.Limit.COMMENTS):
|
||||
comment_chance = 0.0
|
||||
|
||||
if res_score >= 0.4 and random.random() < comment_chance:
|
||||
# If user explicitly configures comment_chance > 0, we lower the strict AI resonance requirement
|
||||
needs_comment = (comment_chance > 0.0 and random.random() < comment_chance)
|
||||
if (needs_comment or res_score >= 0.4):
|
||||
|
||||
logger.info("💬 [Interaction] Entering Comment Sheet for deep engagement...")
|
||||
success = nav_graph._execute_transition("tap_comment_button", zero_engine)
|
||||
success = nav_graph._execute_transition("tap_comment_button")
|
||||
if success is True:
|
||||
sleep(random.uniform(2.0, 4.0) * sleep_mod)
|
||||
|
||||
# 1. Scrape Context from the comment sheet
|
||||
sheet_xml = device.deviceV2.dump_hierarchy()
|
||||
sheet_xml = device.dump_hierarchy()
|
||||
|
||||
# 🛡️ [Semantic Gate] Verify we are actually in the comment sheet
|
||||
if not any(x in sheet_xml for x in ["layout_comment_thread", "comment_composer", "comment_button_post"]):
|
||||
@@ -1185,7 +1292,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
if random.random() < 0.4:
|
||||
# Use Telepathic to find the like button for this specific comment text
|
||||
intent = f"Heart like button for comment: '{c_node['text'][:20]}...'"
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
like_btn = telepathic.find_best_node(xml_dump, intent, device=device)
|
||||
|
||||
if like_btn and not like_btn.get("skip"):
|
||||
@@ -1193,7 +1300,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
sleep(random.uniform(0.8, 1.5))
|
||||
|
||||
# Verification: Simple XML change check
|
||||
if device.deviceV2.dump_hierarchy() != xml_dump:
|
||||
if device.dump_hierarchy() != xml_dump:
|
||||
telepathic.confirm_click(intent)
|
||||
logger.info(f"❤️ [Interaction] Liked user comment: '{c_node['text'][:30]}...'")
|
||||
else:
|
||||
@@ -1202,7 +1309,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
# 20% chance to randomly visit commenter's profile
|
||||
if random.random() < 0.2:
|
||||
intent = f"Avatar profile picture for commenter: '{c_node['text'][:20]}...'"
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
avatar_node = telepathic.find_best_node(xml_dump, intent, device=device)
|
||||
|
||||
if avatar_node:
|
||||
@@ -1211,7 +1318,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
sleep(random.uniform(2.5, 4.5) * sleep_mod)
|
||||
|
||||
# Verification: Did we reach a profile?
|
||||
post_xml = device.deviceV2.dump_hierarchy()
|
||||
post_xml = device.dump_hierarchy()
|
||||
if "profile" in post_xml.lower() or "button_follow" in post_xml.lower():
|
||||
telepathic.confirm_click(intent)
|
||||
_interact_with_profile(device, configs, "commenter", session_state, sleep_mod, logger)
|
||||
@@ -1225,7 +1332,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
# 15% chance to Sub-Comment (Reply)
|
||||
if random.random() < 0.15 and not replying_to:
|
||||
intent = f"Reply button for comment: '{c_node['text'][:20]}...'"
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
reply_btn = telepathic.find_best_node(xml_dump, intent, device=device)
|
||||
|
||||
if reply_btn:
|
||||
@@ -1233,7 +1340,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
sleep(random.uniform(1.2, 2.0))
|
||||
|
||||
# Verification: Did the screen change or input field appear?
|
||||
if device.deviceV2.dump_hierarchy() != xml_dump:
|
||||
if device.dump_hierarchy() != xml_dump:
|
||||
telepathic.confirm_click(intent)
|
||||
replying_to = c_node["text"]
|
||||
logger.info(f"🔁 [Interaction] Replying directly to comment: '{replying_to[:30]}...'")
|
||||
@@ -1290,7 +1397,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
|
||||
# Verification: Did the keyboard open or cursor move to box?
|
||||
# We check if the XML changed and focus is on an edittext
|
||||
post_focus_xml = device.deviceV2.dump_hierarchy()
|
||||
post_focus_xml = device.dump_hierarchy()
|
||||
if "editText" in post_focus_xml.lower() or post_focus_xml != sheet_xml:
|
||||
telepathic.confirm_click("Comment input text box editfield")
|
||||
else:
|
||||
@@ -1312,7 +1419,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
# Press back to trigger Discard popup
|
||||
device.deviceV2.press("back")
|
||||
sleep(1.0)
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
discard_btn = telepathic.find_best_node(xml_dump, "Discard or Verwerfen popup button to cancel comment", device=device)
|
||||
if discard_btn:
|
||||
device.deviceV2.click(discard_btn["x"], discard_btn["y"])
|
||||
@@ -1323,14 +1430,14 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
else:
|
||||
# Tap Post
|
||||
sleep(random.uniform(0.5, 1.5))
|
||||
pre_post_xml = device.deviceV2.dump_hierarchy()
|
||||
pre_post_xml = device.dump_hierarchy()
|
||||
post_btn = telepathic.find_best_node(pre_post_xml, "Post submit comment button", device=device)
|
||||
if post_btn:
|
||||
device.deviceV2.click(post_btn["x"], post_btn["y"])
|
||||
sleep(random.uniform(2.0, 3.5))
|
||||
|
||||
# Verification: Did the button disappear or layout change?
|
||||
post_post_xml = device.deviceV2.dump_hierarchy()
|
||||
post_post_xml = device.dump_hierarchy()
|
||||
# If "Post" button is gone from the area or XML changed significantly
|
||||
if "button_post" not in post_post_xml.lower() or post_post_xml != pre_post_xml:
|
||||
telepathic.confirm_click("Post submit comment button")
|
||||
@@ -1344,10 +1451,10 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
logger.error(f"❌ [Interaction] AI Comment deployment failed: {e}")
|
||||
|
||||
# Safely exit the comment sheet
|
||||
if "bottom_sheet_container" in device.deviceV2.dump_hierarchy():
|
||||
if "bottom_sheet_container" in device.dump_hierarchy():
|
||||
device.deviceV2.press("back")
|
||||
sleep(1.0)
|
||||
if "bottom_sheet_container" in device.deviceV2.dump_hierarchy():
|
||||
if "bottom_sheet_container" in device.dump_hierarchy():
|
||||
device.deviceV2.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
@@ -1357,31 +1464,44 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
repost_chance = float(getattr(configs.args, "repost_percentage", 0)) / 100.0
|
||||
if res_score >= 0.70 and random.random() < repost_chance:
|
||||
logger.info("🔁 [Interaction] Reposting highly resonant content...", extra={"color": f"{Fore.CYAN}"})
|
||||
success = nav_graph._execute_transition("tap_share_button", zero_engine)
|
||||
if success is True:
|
||||
sleep(random.uniform(1.8, 3.5) * sleep_mod)
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
repost_btn = telepathic.find_best_node(xml_dump, "Repost interaction button with two arrows", device=device)
|
||||
if repost_btn and not repost_btn.get("skip"):
|
||||
_humanized_click(device, repost_btn["x"], repost_btn["y"], sleep_mod=sleep_mod)
|
||||
sleep(random.uniform(2.0, 4.0) * sleep_mod)
|
||||
|
||||
# Fast Path: Check if Repost button is ALREADY on the screen (Direct Repost for Reels)
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
current_xml = device.dump_hierarchy()
|
||||
direct_repost = telepathic.find_best_node(current_xml, "Repost interaction button with two arrows", device=device, threshold=0.90) if is_reels else None
|
||||
|
||||
success = True
|
||||
if direct_repost and not direct_repost.get("skip"):
|
||||
logger.info("⚡ [Fast Path] Found direct Repost button. Skipping share sheet.")
|
||||
repost_btn = direct_repost
|
||||
else:
|
||||
success = nav_graph._execute_transition("tap_share_button")
|
||||
if success is True:
|
||||
sleep(random.uniform(1.8, 3.5) * sleep_mod)
|
||||
xml_dump = device.dump_hierarchy()
|
||||
repost_btn = telepathic.find_best_node(xml_dump, "Repost interaction button with two arrows", device=device)
|
||||
else:
|
||||
repost_btn = None
|
||||
|
||||
if success is True and repost_btn and not repost_btn.get("skip"):
|
||||
_humanized_click(device, repost_btn["x"], repost_btn["y"], sleep_mod=sleep_mod)
|
||||
sleep(random.uniform(2.0, 4.0) * sleep_mod)
|
||||
|
||||
# Verification: Did the share menu close or repost confirmation appear?
|
||||
post_xml = device.dump_hierarchy()
|
||||
repost_success = post_xml != current_xml or "reposted" in post_xml.lower()
|
||||
|
||||
if repost_success:
|
||||
telepathic.confirm_click("Repost interaction button with two arrows")
|
||||
logger.info("✅ [Interaction] Content successfully reposted to feed/followers.", extra={"color": f"{Fore.GREEN}"})
|
||||
did_interact = True
|
||||
else:
|
||||
telepathic.reject_click("Repost interaction button with two arrows")
|
||||
logger.warning("⚠️ [Repost] Click failed to trigger repost. Learning from failure.")
|
||||
|
||||
# Verification: Did the share menu close or repost confirmation appear?
|
||||
post_xml = device.deviceV2.dump_hierarchy()
|
||||
repost_success = post_xml != xml_dump or "reposted" in post_xml.lower()
|
||||
|
||||
if repost_success:
|
||||
telepathic.confirm_click("Repost interaction button with two arrows")
|
||||
logger.info("✅ [Interaction] Content successfully reposted to feed/followers.", extra={"color": f"{Fore.GREEN}"})
|
||||
did_interact = True
|
||||
else:
|
||||
telepathic.reject_click("Repost interaction button with two arrows")
|
||||
logger.warning("⚠️ [Repost] Click failed to trigger repost. Learning from failure.")
|
||||
|
||||
# Close share menu if still open
|
||||
device.deviceV2.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
# Close share menu if still open
|
||||
device.deviceV2.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
|
||||
|
||||
# ── Parasocial CRM & SwarmProtocol ──
|
||||
@@ -1408,7 +1528,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
# ── Active Inference: Evaluate prediction (after action) ──
|
||||
if ai:
|
||||
_wait_for_post_loaded(device, timeout=3)
|
||||
post_action_xml = device.deviceV2.dump_hierarchy()
|
||||
post_action_xml = device.dump_hierarchy()
|
||||
ai.evaluate_prediction(post_action_xml)
|
||||
|
||||
# ── Advance to next post ──
|
||||
@@ -1454,7 +1574,7 @@ def _run_zero_latency_search_loop(device, zero_engine, nav_graph, configs, sessi
|
||||
# We assume we are on the Explore tab now (Global Navigation Bar)
|
||||
|
||||
try:
|
||||
xml = device.deviceV2.dump_hierarchy()
|
||||
xml = device.dump_hierarchy()
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
|
||||
# Find search bar
|
||||
@@ -1467,7 +1587,7 @@ def _run_zero_latency_search_loop(device, zero_engine, nav_graph, configs, sessi
|
||||
sleep(3.0)
|
||||
|
||||
# 2. Pick a result (Top, Accounts, or Tags)
|
||||
results_xml = device.deviceV2.dump_hierarchy()
|
||||
results_xml = device.dump_hierarchy()
|
||||
target_result = telepathic.find_best_node(results_xml, "First relevant search result (Account or Hashtag)", device=device)
|
||||
|
||||
if target_result:
|
||||
|
||||
@@ -171,6 +171,7 @@ class Config:
|
||||
self.parser.add_argument("--ai-target-audience", help="Target audience used interchangeably with persona interests", default="")
|
||||
self.parser.add_argument("--interact-percentage", help="Overall interaction probability percentage", default="80")
|
||||
self.parser.add_argument("--comment-percentage", help="Comment probability percentage", default="0")
|
||||
self.parser.add_argument("--follow-percentage", help="Follow probability percentage", default="0")
|
||||
self.parser.add_argument("--dry-run-comments", action="store_true", help="Generate AI comments but do not actually post them (debug/logging only)")
|
||||
self.parser.add_argument("--search", help="Comma-separated keywords to search for", default="")
|
||||
self.parser.add_argument("--scrape-profiles", action="store_true", help="Extract and store profile metadata in CRM")
|
||||
|
||||
@@ -30,10 +30,10 @@ class DarwinEngine(QdrantBase):
|
||||
}
|
||||
self.current_behavior = {}
|
||||
|
||||
def synthesize_interaction_profile(self, target_resonance: float) -> dict:
|
||||
def synthesize_interaction_profile(self, target_resonance: float, text_length: int = 0) -> dict:
|
||||
"""
|
||||
Given an AI aesthetic resonance score (0.0 to 1.0), this generates
|
||||
a deterministic topological interaction behavior mathematically suited to the target.
|
||||
Given an AI aesthetic resonance score (0.0 to 1.0) and caption length,
|
||||
this generates a deterministic topological interaction behavior.
|
||||
"""
|
||||
history = self._get_historical_landscape()
|
||||
epsilon = 0.15 # 15% pure exploration
|
||||
@@ -54,18 +54,26 @@ class DarwinEngine(QdrantBase):
|
||||
self.current_behavior["initial_dwell_sec"] *= max(0.5, target_resonance * 1.5)
|
||||
self.current_behavior["profile_visit_prob"] *= max(0.2, target_resonance * 2.0)
|
||||
|
||||
# ── Generative Dwell-Time ──
|
||||
# Humans take longer to finish "reading" long captions.
|
||||
# Average reading speed is ~15-20 chars per second.
|
||||
if text_length > 20:
|
||||
reading_latency = min(15.0, text_length / 25.0) # Cap extra reading time at 15s
|
||||
logger.debug(f"🧬 [Darwin Engine] Generative Dwell spike: +{reading_latency:.1f}s (Caption: {text_length} chars)")
|
||||
self.current_behavior["initial_dwell_sec"] += reading_latency
|
||||
|
||||
# Clip bounds
|
||||
for k, (b_min, b_max, _) in self.behavior_bounds.items():
|
||||
self.current_behavior[k] = max(b_min, min(b_max, self.current_behavior[k]))
|
||||
|
||||
return self.current_behavior
|
||||
|
||||
def execute_proof_of_resonance(self, device, resonance: float, nav_graph=None, zero_engine=None, configs=None, resonance_oracle=None, username=None):
|
||||
def execute_proof_of_resonance(self, device, resonance: float, text_length: int = 0, nav_graph=None, zero_engine=None, configs=None, resonance_oracle=None, username=None):
|
||||
"""
|
||||
Translates the mathematical interaction profile directly into device actions
|
||||
to prove engagement to the platform's anti-bot heuristic algorithm.
|
||||
"""
|
||||
profile = self.synthesize_interaction_profile(resonance)
|
||||
profile = self.synthesize_interaction_profile(resonance, text_length=text_length)
|
||||
|
||||
logger.info("🧬 [Darwin MDP] Executing Proof of Resonance Sequence...")
|
||||
|
||||
@@ -124,7 +132,7 @@ class DarwinEngine(QdrantBase):
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Vision Context] Failed to capture screenshot: {e}")
|
||||
|
||||
success = nav_graph._execute_transition("tap_comment_button", zero_engine)
|
||||
success = nav_graph._execute_transition("tap_comment_button")
|
||||
if success:
|
||||
# ---- Phase 10: RAG Comment Extraction ----
|
||||
if configs and resonance_oracle and getattr(configs.args, "ai_learn_comments", False):
|
||||
@@ -132,7 +140,7 @@ class DarwinEngine(QdrantBase):
|
||||
if random.random() < 0.05:
|
||||
logger.debug(" -> Dumping UI hierarchy for Comment Extraction...")
|
||||
try:
|
||||
xml_data = device.deviceV2.dump_hierarchy()
|
||||
xml_data = device.dump_hierarchy()
|
||||
t0 = time.time()
|
||||
resonance_oracle.extract_and_learn_comments(xml_data, configs, author=username or "unknown", images_b64=b64_img_payload)
|
||||
t1 = time.time()
|
||||
@@ -154,7 +162,7 @@ class DarwinEngine(QdrantBase):
|
||||
time.sleep(1.0)
|
||||
# Instead of relying on a fragile bottom_sheet_container ID,
|
||||
# we verify if the feed is visible. If not, the comment sheet is still open (or keyboard).
|
||||
ui_dump = device.deviceV2.dump_hierarchy()
|
||||
ui_dump = device.dump_hierarchy()
|
||||
if 'resource-id="com.instagram.android:id/row_feed"' not in ui_dump and 'resource-id="com.instagram.android:id/button_like"' not in ui_dump:
|
||||
logger.debug(" -> Not back on Home feed, pressing back again to close comment sheet/keyboard")
|
||||
device.deviceV2.press("back")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import json
|
||||
import uiautomator2 as u2
|
||||
from time import sleep
|
||||
from time import sleep, time
|
||||
from random import uniform
|
||||
from GramAddict.core.utils import random_sleep
|
||||
from functools import wraps
|
||||
@@ -67,6 +67,10 @@ class DeviceFacade:
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not start system watcher: {e}")
|
||||
|
||||
# Transient package cache to prevent repeat lag
|
||||
self.last_transient_pkg = None
|
||||
self.last_transient_time = 0
|
||||
|
||||
@adb_retry()
|
||||
def get_info(self):
|
||||
return self.deviceV2.info
|
||||
@@ -123,11 +127,11 @@ class DeviceFacade:
|
||||
# Human finger rest time (squish)
|
||||
sleep(uniform(0.05, 0.15))
|
||||
|
||||
# Sloppy slip
|
||||
slip_x = x + int(uniform(-4, 4))
|
||||
# Sloppy slip (Containment: Don't slip horizontally at the bottom edge, prevents Android App-Switch gestures)
|
||||
slip_x = x + int(uniform(-4, 4)) if y < 2100 else x
|
||||
slip_y = y + int(uniform(-4, 4))
|
||||
self.deviceV2.touch.move(slip_x, slip_y)
|
||||
|
||||
self.deviceV2.touch.move(slip_x, slip_y)
|
||||
sleep(uniform(0.01, 0.05))
|
||||
self.deviceV2.touch.up(slip_x, slip_y)
|
||||
except Exception as e:
|
||||
@@ -150,15 +154,35 @@ class DeviceFacade:
|
||||
def _get_current_app(self):
|
||||
"""
|
||||
Hardened app package detection.
|
||||
Transient notifications (e.g. Amazon, WhatsApp) can spoof uiautomator2's app_current() report.
|
||||
We verify the package with a retry if it doesn't match our expected app_id.
|
||||
Transient notifications (e.g. Amazon, WhatsApp, SystemUI) can spoof uiautomator2's app_current() report.
|
||||
We verify the package with multiple retries and a grace period if it doesn't match our expected app_id.
|
||||
"""
|
||||
pkg = self.deviceV2.app_current().get("package")
|
||||
if pkg != self.app_id:
|
||||
# Maybe a notification popped up? Wait and re-check.
|
||||
sleep(0.5)
|
||||
pkg = self.deviceV2.app_current().get("package")
|
||||
if pkg == self.app_id:
|
||||
return pkg
|
||||
|
||||
# If it doesn't match, it might be a notification banner.
|
||||
# Known transient spoofers: WhatsApp, SystemUI (status bar), Android System
|
||||
transient_packages = ["com.whatsapp", "com.android.systemui", "android"]
|
||||
|
||||
if pkg in transient_packages:
|
||||
# Check cooldown: if we just handled this package < 10s ago, don't sleep again
|
||||
now = time()
|
||||
if pkg == self.last_transient_pkg and (now - self.last_transient_time) < 10.0:
|
||||
logger.debug(f"Perimeter: Consecutive hit for transient package '{pkg}'. Skipping cooldown wait.")
|
||||
return self.app_id
|
||||
|
||||
logger.debug(f"⚠️ [Perimeter] Detected transient package '{pkg}'. Waiting for banner to clear...")
|
||||
self.last_transient_pkg = pkg
|
||||
self.last_transient_time = now
|
||||
sleep(1.5) # Give the notification/animation time to fade
|
||||
|
||||
pkg = self.deviceV2.app_current().get("package")
|
||||
if pkg in transient_packages:
|
||||
# If it persists, we trust the drift logic to handle it if it blocks the UI,
|
||||
# but for focus detection, we return the target app to avoid infinite wait loops.
|
||||
return self.app_id
|
||||
|
||||
return pkg
|
||||
|
||||
@adb_retry()
|
||||
@@ -168,7 +192,26 @@ class DeviceFacade:
|
||||
|
||||
@adb_retry()
|
||||
def dump_hierarchy(self):
|
||||
return self.deviceV2.dump_hierarchy()
|
||||
xml = self.deviceV2.dump_hierarchy()
|
||||
|
||||
# Continuous Session Tracing
|
||||
import os
|
||||
from datetime import datetime
|
||||
try:
|
||||
if not hasattr(self, "_trace_counter"):
|
||||
self._trace_counter = 0
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
self._trace_dir = os.path.join("debug", "session_traces", ts)
|
||||
os.makedirs(self._trace_dir, exist_ok=True)
|
||||
|
||||
self._trace_counter += 1
|
||||
trace_path = os.path.join(self._trace_dir, f"{self._trace_counter:05d}.xml")
|
||||
with open(trace_path, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write session trace: {e}")
|
||||
|
||||
return xml
|
||||
|
||||
@adb_retry()
|
||||
def screenshot(self):
|
||||
|
||||
@@ -35,7 +35,7 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
os.makedirs(DUMP_DIR, exist_ok=True)
|
||||
|
||||
# Capture hierarchy
|
||||
xml = device.deviceV2.dump_hierarchy()
|
||||
xml = device.dump_hierarchy()
|
||||
|
||||
# Generate filename: reason__2026-04-13_17-41-39.xml
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
@@ -36,7 +36,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
try:
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
# Step 1: Find unread conversation threads
|
||||
unread_threads = telepathic._extract_semantic_nodes(xml_dump, "find unread message threads or unread badges", threshold=0.7)
|
||||
@@ -48,7 +48,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
sleep(2.0)
|
||||
|
||||
# Step 2: Read the conversation context
|
||||
thread_xml = device.deviceV2.dump_hierarchy()
|
||||
thread_xml = device.dump_hierarchy()
|
||||
msg_nodes = telepathic._extract_semantic_nodes(thread_xml, "find the last received message text", threshold=0.6)
|
||||
|
||||
context_text = "No previous context"
|
||||
@@ -76,7 +76,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
sleep(1.0)
|
||||
|
||||
# Find Send button
|
||||
send_xml = device.deviceV2.dump_hierarchy()
|
||||
send_xml = device.dump_hierarchy()
|
||||
send_nodes = telepathic._extract_semantic_nodes(send_xml, "find the send message button", threshold=0.8)
|
||||
|
||||
if send_nodes and not send_nodes[0].get("skip"):
|
||||
|
||||
@@ -21,7 +21,7 @@ def capture_all(device):
|
||||
def _save_dump(filename, description):
|
||||
logger.info(f"⏳ Waiting for UI to settle for [{description}]...")
|
||||
time.sleep(3.5) # ensure animations finish
|
||||
xml_data = device.deviceV2.dump_hierarchy()
|
||||
xml_data = device.dump_hierarchy()
|
||||
path = os.path.join(FIX_DIR, filename)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(xml_data)
|
||||
|
||||
3
GramAddict/core/exceptions.py
Normal file
3
GramAddict/core/exceptions.py
Normal file
@@ -0,0 +1,3 @@
|
||||
class ActionBlockedError(Exception):
|
||||
"""Raised when Instagram explicitly blocks an action with a 'Try Again Later' or 'Action Blocked' dialogue."""
|
||||
pass
|
||||
@@ -59,12 +59,70 @@ def get_model_pricing(model_id: str) -> dict:
|
||||
|
||||
return _MODEL_PRICING_CACHE.get(model_id, {})
|
||||
|
||||
def prewarm_ollama_models(configs):
|
||||
"""
|
||||
Sends a dummy request to the configured local Ollama API endpoints via a background thread
|
||||
to force the models to load into VRAM during bot startup, minimizing initial connection latency
|
||||
and avoiding timeouts downstream.
|
||||
"""
|
||||
args = configs.args
|
||||
|
||||
def _warmup():
|
||||
import threading
|
||||
models_to_warm = set()
|
||||
|
||||
# Collect unique local models
|
||||
for attr, url_attr in [
|
||||
("ai_telepathic_model", "ai_telepathic_url"),
|
||||
("ai_fallback_model", "ai_fallback_url"),
|
||||
("ai_condenser_model", "ai_condenser_url"),
|
||||
("ai_model", "ai_model_url")
|
||||
]:
|
||||
url = getattr(args, url_attr, "")
|
||||
model = getattr(args, attr, "")
|
||||
if model and url and ("localhost" in url or "127.0.0.1" in url):
|
||||
models_to_warm.add((url, model))
|
||||
|
||||
for url, model in models_to_warm:
|
||||
logger.info(f"🔥 [VRAM Pre-Warm] Instructing local Ollama engine to load {model} into memory in the background...")
|
||||
try:
|
||||
# Fire an ultra-short generation to force it into VRAM
|
||||
requests.post(
|
||||
url,
|
||||
json={"model": model, "prompt": "Hi", "stream": False, "options": {"num_predict": 1}},
|
||||
timeout=120
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(args, "ai_telepathic_model"):
|
||||
import threading
|
||||
threading.Thread(target=_warmup, daemon=True).start()
|
||||
|
||||
def log_openrouter_burn():
|
||||
"""Fetches and logs the current OpenRouter API key usage (money burned)."""
|
||||
"""Fetches and logs the current OpenRouter API key usage (money burned) ONLY if OpenRouter is actively used."""
|
||||
key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
return
|
||||
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
args = Config().args
|
||||
uses_openrouter = False
|
||||
|
||||
# Check all possible model/url endpoints for 'openrouter'
|
||||
for attr in ["ai_model", "ai_model_url", "ai_telepathic_model", "ai_telepathic_url",
|
||||
"ai_fallback_model", "ai_fallback_url", "ai_condenser_model", "ai_condenser_url"]:
|
||||
val = getattr(args, attr, "")
|
||||
if val and "openrouter" in str(val).lower():
|
||||
uses_openrouter = True
|
||||
break
|
||||
|
||||
if not uses_openrouter:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
r = requests.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"}, timeout=5)
|
||||
if r.status_code == 200:
|
||||
@@ -281,6 +339,9 @@ def query_telepathic_llm(
|
||||
except Exception:
|
||||
target_url = "http://localhost:11434/api/generate"
|
||||
target_model = "llama3.2:1b"
|
||||
|
||||
is_local = "localhost" in target_url or "127.0.0.1" in target_url
|
||||
calc_timeout = 180 if is_local else 45
|
||||
|
||||
ans = query_llm(
|
||||
url=target_url,
|
||||
@@ -288,7 +349,8 @@ def query_telepathic_llm(
|
||||
prompt=user_prompt,
|
||||
images_b64=images_b64,
|
||||
system=system_prompt,
|
||||
format_json=True
|
||||
format_json=True,
|
||||
timeout=calc_timeout # Navigation VLM must fail fast for Cloud, but wait for Local VRAM loads
|
||||
)
|
||||
if ans and "response" in ans:
|
||||
return ans["response"]
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import uuid
|
||||
import time
|
||||
import random
|
||||
from GramAddict.core.utils import random_sleep
|
||||
from GramAddict.core.compiler_engine import VLMCompilerEngine
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB
|
||||
|
||||
@@ -92,7 +93,7 @@ class QNavGraph:
|
||||
direct_action = target_to_action.get(target_state, "tap_home_tab")
|
||||
target_anchor = target_state if direct_action != "tap_home_tab" else "HomeFeed"
|
||||
|
||||
success = self._execute_transition(direct_action, zero_engine)
|
||||
success = self._execute_transition(direct_action)
|
||||
if success is True:
|
||||
logger.info(f"Successfully anchored! Learned new global edge: {self.current_state} -> {target_anchor} via {direct_action}")
|
||||
if self.current_state not in self.nodes:
|
||||
@@ -105,7 +106,7 @@ class QNavGraph:
|
||||
elif success == "CONTEXT_LOST":
|
||||
logger.warning(f"⚠️ Context was lost during direct action '{direct_action}'. Forcing app focus and resetting path.")
|
||||
self.device.deviceV2.app_start(self.device.app_id, use_monkey=True)
|
||||
time.sleep(3)
|
||||
random_sleep(2.5, 4.0)
|
||||
self.current_state = "HomeFeed"
|
||||
return self.navigate_to(target_state, zero_engine, recovery_attempts=recovery_attempts + 1)
|
||||
else:
|
||||
@@ -113,7 +114,7 @@ class QNavGraph:
|
||||
if self.current_state == "UNKNOWN":
|
||||
logger.warning(f"📍 [Recovery] Semantic tap failed from UNKNOWN. Attempting to back out of sub-view...")
|
||||
self.device.deviceV2.press("back")
|
||||
time.sleep(2)
|
||||
random_sleep(1.5, 3.0)
|
||||
# We stay in UNKNOWN, but next attempt might see the nav bar
|
||||
return self.navigate_to(target_state, zero_engine, recovery_attempts=recovery_attempts + 0.5)
|
||||
path = None
|
||||
@@ -122,7 +123,7 @@ class QNavGraph:
|
||||
# Absolute last resort fallback: force app to main activity
|
||||
logger.warning("Semantic recovery failed. Forcing main activity intent...")
|
||||
self.device.deviceV2.app_start(self.device.app_id)
|
||||
time.sleep(3)
|
||||
random_sleep(2.5, 4.0)
|
||||
self.current_state = "HomeFeed"
|
||||
path = self._find_path(self.current_state, logical_target)
|
||||
|
||||
@@ -131,12 +132,12 @@ class QNavGraph:
|
||||
return False
|
||||
|
||||
for action in path:
|
||||
result = self._execute_transition(action, zero_engine)
|
||||
result = self._execute_transition(action)
|
||||
|
||||
if result == "CONTEXT_LOST":
|
||||
logger.warning(f"⚠️ Context was lost during '{action}'. Forcing app focus and resetting path.")
|
||||
self.device.deviceV2.app_start(self.device.app_id, use_monkey=True)
|
||||
time.sleep(3)
|
||||
random_sleep(2.5, 4.0)
|
||||
# After app start, we are at HomeFeed (usually)
|
||||
self.current_state = "HomeFeed"
|
||||
# Recursively call navigate_to from the new anchor
|
||||
@@ -146,7 +147,7 @@ class QNavGraph:
|
||||
logger.error(f"Nav transition '{action}' failed! Initiating self-repair...")
|
||||
self._repair_transition(action)
|
||||
# Retry after repair
|
||||
success = self._execute_transition(action, zero_engine)
|
||||
success = self._execute_transition(action)
|
||||
if not success or success == "CONTEXT_LOST":
|
||||
logger.error(f"FATAL: Auto-repair failed for transition: {action}")
|
||||
return False
|
||||
@@ -175,24 +176,133 @@ class QNavGraph:
|
||||
|
||||
return None
|
||||
|
||||
def _execute_transition(self, action: str, zero_engine=None, max_retries: int = 2) -> bool:
|
||||
def _clear_anomaly_obstacles(self, max_attempts=2) -> bool:
|
||||
"""
|
||||
Actively hunts down and dismisses known edge-case overlays (OS Permissions, Surveys)
|
||||
that block navigation. If an unknown modal is detected, falls back to pressing BACK.
|
||||
Returns True if an obstacle was detected and handled, False if the UI is clear.
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import time
|
||||
from GramAddict.core.exceptions import ActionBlockedError
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
xml_dump = self.device.dump_hierarchy()
|
||||
if not isinstance(xml_dump, str):
|
||||
return False
|
||||
|
||||
xml_dump_lower = xml_dump.lower()
|
||||
|
||||
# --- 0. FATAL: Action Blocked Guard ---
|
||||
# If Instagram explicitly restricts our activity, we must hard crash to prevent permanent account bans.
|
||||
is_action_blocked = (
|
||||
"try again later" in xml_dump_lower or
|
||||
"action blocked" in xml_dump_lower or
|
||||
"restrict certain activity" in xml_dump_lower or
|
||||
"help us confirm you own" in xml_dump_lower or
|
||||
"confirm it's you" in xml_dump_lower or
|
||||
"später erneut versuchen" in xml_dump_lower or
|
||||
"bestätige, dass du es bist" in xml_dump_lower or
|
||||
"handlung blockiert" in xml_dump_lower or
|
||||
"eingeschränkt" in xml_dump_lower
|
||||
)
|
||||
|
||||
if is_action_blocked:
|
||||
logger.error("🚫 [CRITICAL GUARD] Instagram Action Block Dialog Detected! Aborting run to protect account.")
|
||||
raise ActionBlockedError("Instagram soft-banned the account. We hit a rate limit or restriction. Halting all activities.")
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(xml_dump)
|
||||
except Exception:
|
||||
# If XML parsing fails, fall back to simple string check
|
||||
if re.search(r'bottom_sheet_container|dialog_container|dialog_root|bottom_sheet_drag|action_sheet_container', xml_dump):
|
||||
logger.warning("🛡️ [Z-Depth Guard] Generic obstacle detected. Pressing BACK to clear...")
|
||||
self.device.deviceV2.press("back")
|
||||
random_sleep(1.0, 2.5)
|
||||
return True
|
||||
return False
|
||||
|
||||
handled = False
|
||||
|
||||
# --- 1. OS Permission Dialogs (Android) ---
|
||||
grant_dialog = tree.find(".//node[@resource-id='com.android.permissioncontroller:id/grant_dialog']")
|
||||
if grant_dialog is not None:
|
||||
logger.warning("🛡️ [Z-Depth Guard] OS Permission Dialog detected! Searching for Deny button...")
|
||||
deny_btn = grant_dialog.find(".//node[@resource-id='com.android.permissioncontroller:id/permission_deny_button']")
|
||||
if deny_btn is not None and deny_btn.get("bounds"):
|
||||
bounds = re.findall(r'\d+', deny_btn.get("bounds"))
|
||||
if len(bounds) == 4:
|
||||
x = (int(bounds[0]) + int(bounds[2])) // 2
|
||||
y = (int(bounds[1]) + int(bounds[3])) // 2
|
||||
logger.info(f"👆 Clicking 'Deny' at ({x}, {y})")
|
||||
from GramAddict.core.bot_flow import _humanized_click
|
||||
_humanized_click(self.device, x, y)
|
||||
random_sleep(1.0, 2.5)
|
||||
handled = True
|
||||
|
||||
# --- 2. Instagram Surveys & Interstitials ---
|
||||
if not handled:
|
||||
survey_cont = tree.find(".//node[@resource-id='com.instagram.android:id/survey_container']")
|
||||
if survey_cont is not None:
|
||||
logger.warning("🛡️ [Z-Depth Guard] Instagram Survey detected! Searching for Dismiss/Not Now button...")
|
||||
|
||||
# Usually the negative button has an explicit ID
|
||||
neg_btn = survey_cont.find(".//node[@resource-id='com.instagram.android:id/button_negative']")
|
||||
|
||||
# Fallback to semantic text search if ID fails
|
||||
if neg_btn is None:
|
||||
for n in survey_cont.iter('node'):
|
||||
txt = n.get("text", "").lower() + " " + n.get("content-desc", "").lower()
|
||||
if "not now" in txt or "cancel" in txt or "dismiss" in txt or "skip" in txt:
|
||||
neg_btn = n
|
||||
break
|
||||
|
||||
if neg_btn is not None and neg_btn.get("bounds"):
|
||||
bounds = re.findall(r'\d+', neg_btn.get("bounds"))
|
||||
if len(bounds) == 4:
|
||||
x = (int(bounds[0]) + int(bounds[2])) // 2
|
||||
y = (int(bounds[1]) + int(bounds[3])) // 2
|
||||
logger.info(f"👆 Clicking Survey Dismiss at ({x}, {y})")
|
||||
from GramAddict.core.bot_flow import _humanized_click
|
||||
_humanized_click(self.device, x, y)
|
||||
random_sleep(1.0, 2.5)
|
||||
handled = True
|
||||
|
||||
# --- 3. Intrusive Bottom / Action Sheets ---
|
||||
if not handled:
|
||||
if re.search(r'bottom_sheet_container|dialog_container|dialog_root|bottom_sheet_drag|action_sheet_container', xml_dump):
|
||||
logger.warning("🛡️ [Z-Depth Guard] Generic obstacle or Action Sheet detected. Pressing BACK to clear...")
|
||||
self.device.deviceV2.press("back")
|
||||
random_sleep(1.0, 2.5)
|
||||
handled = True
|
||||
|
||||
if handled:
|
||||
# Loop around: could be multiple stacked dialogs
|
||||
continue
|
||||
else:
|
||||
# No known anomaly obstacles detected
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _execute_transition(self, action: str, mock_semantic_engine=None, max_retries: int = 2) -> bool:
|
||||
"""
|
||||
Executes a transition (e.g. 'tap_explore_tab') using the Telepathic Semantic Engine.
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
engine = TelepathicEngine.get_instance()
|
||||
engine = mock_semantic_engine or TelepathicEngine.get_instance()
|
||||
|
||||
failed_positions = set() # Track (x, y) of clicks that failed, for grid retry diversity
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
context_xml = self.device.deviceV2.dump_hierarchy()
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
|
||||
# ── Z-Depth Guard / Obstacle Clearance ──
|
||||
import re
|
||||
if re.search(r'bottom_sheet_container|dialog_container|dialog_root|bottom_sheet_drag', str(context_xml)):
|
||||
logger.warning("🛡️ [Z-Depth Guard] Obstacle overlay detected during navigation. Pressing BACK to clear...")
|
||||
self.device.deviceV2.press("back")
|
||||
time.sleep(1.5)
|
||||
# ── Z-Depth Guard / Anomaly Obstacle Clearance ──
|
||||
cleared_something = self._clear_anomaly_obstacles()
|
||||
if cleared_something:
|
||||
# Re-acquire context after clearing obstacle
|
||||
context_xml = self.device.deviceV2.dump_hierarchy()
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
|
||||
# We phrase the action as an intent for the semantic engine
|
||||
# e.g. "tap_explore_tab" -> "tap explore tab"
|
||||
@@ -223,7 +333,20 @@ class QNavGraph:
|
||||
|
||||
# Use TelepathicEngine to find the most likely node for this intent
|
||||
# If vector score < 0.82, it will trigger the Vision Cortex Fallback (VLM)
|
||||
best_node = engine.find_best_node(context_xml, intent_description, min_confidence=0.82, device=self.device)
|
||||
# Pass failed_positions so grid fast-path picks a different item on retry
|
||||
best_node = engine.find_best_node(context_xml, intent_description, min_confidence=0.82, device=self.device, skip_positions=failed_positions)
|
||||
|
||||
# ── Blocked by Modal Recovery ──
|
||||
if best_node and best_node.get("blocked_by_modal"):
|
||||
logger.warning(f"🛡️ [Modal Recovery] Navigation '{action}' is blocked by a modal. Attempting anomaly clearance...")
|
||||
self._clear_anomaly_obstacles()
|
||||
if attempt < max_retries:
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
continue
|
||||
else:
|
||||
logger.error(f"❌ [Modal Recovery] Persistent blockage for '{action}'. Escalating to Context Lost (App Restart).")
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
if not best_node:
|
||||
logger.debug(f"_execute_transition: TelepathicEngine found no matching node for '{action}'")
|
||||
# Check if we are even in the right app
|
||||
@@ -236,6 +359,15 @@ class QNavGraph:
|
||||
if attempt < max_retries:
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
# FINAL ATTEMPT ESCAPE:
|
||||
# If we are looking for the 'Home' tab (our baseline) and everything failed,
|
||||
# we might be in an unknown sub-view. Try one last 'BACK' press.
|
||||
if action == "tap_home_tab":
|
||||
logger.warning("📍 [Escape] Home tab not found after all retries. Attempting final BACK press to escape sub-view...")
|
||||
self.device.deviceV2.press("back")
|
||||
time.sleep(2.0)
|
||||
|
||||
return False
|
||||
|
||||
if best_node.get("skip") or (best_node.get("selected") and "tab" in action):
|
||||
@@ -250,7 +382,23 @@ class QNavGraph:
|
||||
time.sleep(random.uniform(1.2, 2.5))
|
||||
|
||||
# ── Post-Click Verification: Did it work? ──
|
||||
post_click_xml = self.device.deviceV2.dump_hierarchy()
|
||||
post_click_xml = self.device.dump_hierarchy()
|
||||
|
||||
# ── App Perimeter Guard ──
|
||||
current_app = self.device._get_current_app()
|
||||
if current_app != self.device.app_id:
|
||||
logger.error(f"🚨 [Perimeter Guard] FATAL: Transition '{action}' caused app to drift to '{current_app}'! Rejecting VLM snippet.")
|
||||
failed_positions.add((best_node["x"], best_node["y"]))
|
||||
engine.reject_click(intent_description)
|
||||
|
||||
# Attempt immediate recovery to main app
|
||||
self.device.deviceV2.press("back")
|
||||
random_sleep(1.0, 2.0)
|
||||
if self.device._get_current_app() != self.device.app_id:
|
||||
self.device.deviceV2.app_start(self.device.app_id, use_monkey=True)
|
||||
|
||||
# Return CONTEXT_LOST immediately to prevent memory poisoning
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# 1. Semantic Verification (Hardened)
|
||||
is_verified = engine.verify_success(intent_description, post_click_xml)
|
||||
@@ -263,6 +411,7 @@ class QNavGraph:
|
||||
return True
|
||||
elif not ui_changed:
|
||||
logger.warning(f"⚠️ [Nav] Click on '{action}' did not change UI. Learning from failure.")
|
||||
failed_positions.add((best_node["x"], best_node["y"]))
|
||||
engine.reject_click(intent_description)
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [Autonomy] UI unchanged. Retrying transition '{action}' ({attempt + 1}/{max_retries})...")
|
||||
@@ -272,6 +421,7 @@ class QNavGraph:
|
||||
else:
|
||||
# UI changed but semantic verification failed (accidental click or false positive)
|
||||
logger.warning(f"❌ [Ambiguity Guard] UI changed after '{action}', but semantic verification FAILED. Rejecting mapping.")
|
||||
failed_positions.add((best_node["x"], best_node["y"]))
|
||||
engine.reject_click(intent_description)
|
||||
|
||||
# Safety: If we're not where we expect to be, try to back out to clear any accidentally opened menus
|
||||
@@ -296,7 +446,7 @@ class QNavGraph:
|
||||
dojo = DojoEngine.get_instance(self.device)
|
||||
|
||||
logger.warning(f"⛩️ [Dojo] Enqueuing auto-labeling job for missing '{action}'.", extra={"color": f"\x1b[36m"})
|
||||
context_xml = self.device.deviceV2.dump_hierarchy()
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
|
||||
dojo.submit_snapshot(
|
||||
heuristic_name=action,
|
||||
|
||||
@@ -119,6 +119,10 @@ class ResonanceEngine:
|
||||
# Map this to a more useful 0.0-1.0 range
|
||||
score = max(0.0, min(1.0, (raw_score - 0.15) / 0.30))
|
||||
|
||||
# ── Contextual Empathy Filter ──
|
||||
# If the content is tragic or highly controversial, we must NOT like it, regardless of interest alignment.
|
||||
score = self._apply_empathy_filter(content_text, score)
|
||||
|
||||
# 5. Store evaluation in ContentMemoryDB for future cache hits
|
||||
classification = "high" if score > 0.7 else "medium" if score > 0.4 else "low"
|
||||
self.content_memory.store_evaluation(
|
||||
@@ -140,6 +144,28 @@ class ResonanceEngine:
|
||||
)
|
||||
return score
|
||||
|
||||
def _apply_empathy_filter(self, text: str, current_score: float) -> float:
|
||||
"""
|
||||
Scans for tragic or sensitive keywords. If found, suppresses resonance
|
||||
to prevent bot-like behavior of liking inappropriate content.
|
||||
"""
|
||||
tragic_keywords = [
|
||||
# English
|
||||
"rip", "rest in peace", "tragedy", "died", "killed", "accident", "shooting",
|
||||
"funeral", "sad news", "memorial", "cancer", "disease", "breaking news",
|
||||
# German
|
||||
"ruhe in frieden", "verstorben", "tragödie", "unfall", "tot", "beerdigung",
|
||||
"trauer", "krebs", "krankheit"
|
||||
]
|
||||
|
||||
text_lower = text.lower()
|
||||
if any(f" {word} " in f" {text_lower} " for word in tragic_keywords):
|
||||
logger.warning("🛡️ [Empathy Filter] Tragic/Sensitive content detected. Suppressing resonance to prevent blind liking.")
|
||||
# Drastically reduce score to "low resonance" zone (avoid liking)
|
||||
return min(current_score, 0.2)
|
||||
|
||||
return current_score
|
||||
|
||||
|
||||
def _classification_to_score(self, classification: str) -> float:
|
||||
"""Converts stored classification back to a usable score."""
|
||||
|
||||
@@ -413,6 +413,14 @@ class TelepathicEngine:
|
||||
if "liked" in desc or "liked" in text:
|
||||
logger.info("⏭️ [Keyword Fast Path] Post is already Liked. Skipping.")
|
||||
return {"x": None, "y": None, "score": 1.0, "semantic": "already_liked", "skip": True}
|
||||
|
||||
# Check for already-followed state
|
||||
if "follow" in intent_description.lower():
|
||||
desc = best_node.get("original_attribs", {}).get("desc", "").lower()
|
||||
text = best_node.get("original_attribs", {}).get("text", "").lower()
|
||||
if re.search(r"\b(following|requested|folgst du|angefragt|gefolgt)\b", desc + " " + text):
|
||||
logger.info("⏭️ [Keyword Fast Path] User is already Followed. Skipping.")
|
||||
return {"x": None, "y": None, "score": 1.0, "semantic": "already_followed", "skip": True}
|
||||
|
||||
logger.info(f"⚡ [Keyword Fast Path] Instant match for '{intent_description}' → {best_node['semantic_string']} (KeyScore: {best_score:.2f})")
|
||||
self._track_click(intent_description, best_node)
|
||||
@@ -429,7 +437,7 @@ class TelepathicEngine:
|
||||
# Core: Find Best Node
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def find_best_node(self, xml_hierarchy: str, intent_description: str, min_confidence: float = 0.82, device=None) -> Optional[dict]:
|
||||
def find_best_node(self, xml_hierarchy: str, intent_description: str, min_confidence: float = 0.82, device=None, **kwargs) -> Optional[dict]:
|
||||
"""
|
||||
Scans the screen and returns the center coordinates (x, y) of the node
|
||||
whose embedding is most mathematically similar to the intent.
|
||||
@@ -445,6 +453,14 @@ class TelepathicEngine:
|
||||
"""
|
||||
logger.debug(f"[TelepathicEngine] Seeking intent: '{intent_description}'")
|
||||
|
||||
# ── Global Intent Guards ──
|
||||
intent_lower = intent_description.lower()
|
||||
if "comment" in intent_lower:
|
||||
xml_lower = str(xml_hierarchy).lower()
|
||||
if "comments are turned off" in xml_lower or "comments on this post" in xml_lower or "kommentare sind deaktiviert" in xml_lower or "eingeschränkt" in xml_lower:
|
||||
logger.info("⏭️ [Telepathic] Comments are disabled on this post. Skipping to prevent VLM hallucination.")
|
||||
return {"x": None, "y": None, "score": 1.0, "semantic": "comments_disabled", "skip": True}
|
||||
|
||||
interactive_nodes = self._extract_semantic_nodes(xml_hierarchy)
|
||||
if not interactive_nodes:
|
||||
logger.debug("[TelepathicEngine] Screen contains no interactable semantic nodes.")
|
||||
@@ -455,12 +471,19 @@ class TelepathicEngine:
|
||||
if interactive_nodes:
|
||||
max_y = max(n.get("y", 0) + n.get("height", 0) // 2 for n in interactive_nodes)
|
||||
if max_y > 100:
|
||||
# If we detect a screen height near standard android heights, don't inflate it.
|
||||
if 2200 < max_y < 2600:
|
||||
screen_height = max_y
|
||||
else:
|
||||
screen_height = int(max_y * 1.02)
|
||||
|
||||
# ── Modal Guard ──
|
||||
# If a bottom sheet or dialog is active, it likely obscures the main navigation tabs.
|
||||
# We scan the raw XML to catch non-interactable modals (like those in Reels traps).
|
||||
is_nav_intent = any(k in intent_lower for k in ["tab", "navigation", "search and explore", "reels", "profile", "home", "message"])
|
||||
if is_nav_intent and self._is_modal_active(interactive_nodes, raw_xml_string=xml_hierarchy):
|
||||
logger.warning(f"🛡️ [Modal Guard] A bottom sheet or dialog is blocking the screen. Refusing to seek nav-intent '{intent_description}'.")
|
||||
return {"blocked_by_modal": True}
|
||||
|
||||
# Pre-filter: Remove structurally implausible nodes and blacklisted mappings
|
||||
viable_nodes = []
|
||||
for node in interactive_nodes:
|
||||
@@ -499,6 +522,14 @@ class TelepathicEngine:
|
||||
):
|
||||
logger.info("⏭️ [Memory] Post is already Liked. Skipping tap to prevent un-liking.")
|
||||
return {"x": None, "y": None, "score": 1.0, "semantic": "already_liked", "skip": True}
|
||||
|
||||
# Prevent un-following
|
||||
if "follow" in intent_description.lower() and re.search(
|
||||
r"\b(following|requested|folgst du|angefragt|gefolgt)\b",
|
||||
n["semantic_string"].lower()
|
||||
):
|
||||
logger.info("⏭️ [Memory] User is already Followed/Requested. Skipping tap to prevent opening the Favorites menu.")
|
||||
return {"x": None, "y": None, "score": 1.0, "semantic": "already_followed", "skip": True}
|
||||
|
||||
logger.debug(f"🧠 [Confirmed Memory] Instant recall: '{intent_description}' → {n['semantic_string']}")
|
||||
self._track_click(intent_description, n)
|
||||
@@ -516,20 +547,10 @@ class TelepathicEngine:
|
||||
low_intent = intent_description.lower()
|
||||
is_grid_intent = any(k in low_intent for k in ["explore grid", "grid item", "first image", "profile grid"])
|
||||
if is_grid_intent:
|
||||
grid_nodes = [n for n in viable_nodes if "image_button" in n.get("resource_id", "")]
|
||||
if grid_nodes:
|
||||
# Sort by Y (topmost first), then X (leftmost first) for "first"
|
||||
grid_nodes.sort(key=lambda n: (n["y"], n["x"]))
|
||||
best = grid_nodes[0]
|
||||
logger.info(f"⚡ [Grid Fast-Path] Matched '{intent_description}' → {best['semantic_string']} (y={best['y']})")
|
||||
self._track_click(intent_description, best)
|
||||
return {
|
||||
"x": best["x"],
|
||||
"y": best["y"],
|
||||
"score": 0.98,
|
||||
"semantic": best["semantic_string"],
|
||||
"source": "grid_fastpath"
|
||||
}
|
||||
skip = kwargs.get("skip_positions", set())
|
||||
grid_result = self._grid_fast_path(intent_description, viable_nodes, skip_positions=skip)
|
||||
if grid_result:
|
||||
return grid_result
|
||||
|
||||
# ── Stage 1.5: Deterministic Keyword Fast Path ──
|
||||
fast_path_result = self._keyword_match_score(intent_description, viable_nodes)
|
||||
@@ -570,6 +591,14 @@ class TelepathicEngine:
|
||||
):
|
||||
logger.info("⏭️ [Telepathic] Post is already Liked. Skipping.")
|
||||
return {"x": None, "y": None, "score": 1.0, "semantic": "already_liked", "skip": True}
|
||||
|
||||
# Prevent un-following
|
||||
if "follow" in intent_description.lower() and re.search(
|
||||
r"\b(following|requested|folgst du|angefragt|gefolgt)\b",
|
||||
best_node["semantic_string"].lower()
|
||||
):
|
||||
logger.info("⏭️ [Telepathic] User is already Followed/Requested. Skipping to prevent opening the Favorites menu.")
|
||||
return {"x": None, "y": None, "score": 1.0, "semantic": "already_followed", "skip": True}
|
||||
|
||||
logger.info(f"✨ [Telepathic Match] '{intent_description}' ➔ {best_node['semantic_string']} (Score: {best_score:.3f})")
|
||||
self._track_click(intent_description, best_node)
|
||||
@@ -594,6 +623,44 @@ class TelepathicEngine:
|
||||
# Click Tracking & Feedback Loop
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _grid_fast_path(self, intent_description: str, viable_nodes: list, skip_positions: set = None) -> Optional[dict]:
|
||||
"""
|
||||
Deterministic grid navigation: filters for image_button nodes,
|
||||
sorts by (y, x), and returns the topmost-leftmost candidate.
|
||||
|
||||
skip_positions: set of (x, y) tuples to skip on retry, ensuring
|
||||
the Fast-Path doesn't re-click a position that already failed.
|
||||
"""
|
||||
if skip_positions is None:
|
||||
skip_positions = set()
|
||||
|
||||
grid_nodes = [n for n in viable_nodes if "image_button" in n.get("resource_id", "")]
|
||||
if not grid_nodes:
|
||||
return None
|
||||
|
||||
# Sort by Y (topmost first), then X (leftmost first)
|
||||
grid_nodes.sort(key=lambda n: (n["y"], n["x"]))
|
||||
|
||||
# Filter out previously-failed positions
|
||||
for candidate in grid_nodes:
|
||||
pos = (candidate["x"], candidate["y"])
|
||||
if pos in skip_positions:
|
||||
continue
|
||||
|
||||
logger.info(f"⚡ [Grid Fast-Path] Matched '{intent_description}' → {candidate['semantic_string']} (y={candidate['y']})")
|
||||
self._track_click(intent_description, candidate)
|
||||
return {
|
||||
"x": candidate["x"],
|
||||
"y": candidate["y"],
|
||||
"score": 0.98,
|
||||
"semantic": candidate["semantic_string"],
|
||||
"source": "grid_fastpath"
|
||||
}
|
||||
|
||||
# All grid nodes were in skip_positions
|
||||
logger.warning(f"⚠️ [Grid Fast-Path] All grid positions exhausted for '{intent_description}'. Falling through to VLM.")
|
||||
return None
|
||||
|
||||
def _track_click(self, intent: str, node: dict):
|
||||
"""Records what we're about to click so confirm/reject can reference it."""
|
||||
TelepathicEngine._last_click_context = {
|
||||
@@ -643,12 +710,6 @@ class TelepathicEngine:
|
||||
Called by the interaction layer when the click did NOT produce the expected result.
|
||||
Adds the mapping to the blacklist (negative learning) so it's never tried again.
|
||||
Also removes it from positive memory if it was cached there.
|
||||
|
||||
Usage:
|
||||
result = telepathic.find_best_node(xml, "tap comment button", device=device)
|
||||
_humanized_click(device, result["x"], result["y"])
|
||||
# ... verify comment sheet did NOT open ...
|
||||
telepathic.reject_click("tap comment button")
|
||||
"""
|
||||
ctx = TelepathicEngine._last_click_context
|
||||
if not ctx:
|
||||
@@ -658,29 +719,33 @@ class TelepathicEngine:
|
||||
sem = ctx["semantic_string"]
|
||||
|
||||
# ── Anti-Poisoning Guard ──
|
||||
# A semantic string that contains NO text and NO description is too generic
|
||||
# to blacklist. Example: "id context: 'image button'" matches ALL buttons
|
||||
# in a grid, so blacklisting it would kill the entire explore page.
|
||||
# NOTE: must use regex word boundary to avoid "context:" matching "text:"
|
||||
# Structural UI elements (Home Tab, Like Button, etc.) should NEVER be globally blacklisted
|
||||
# because they are essential for navigation and are unlikely to cause app-drift (only Ads do).
|
||||
structural_intents = {"tap home tab", "tap reels tab", "tap explore tab", "tap newsfeed_tab", "tap like button", "tap comment_button", "tap share button"}
|
||||
|
||||
has_text = bool(re.search(r'(?<!\w)text:', sem.lower()))
|
||||
has_desc = bool(re.search(r'(?<!\w)description:', sem.lower()))
|
||||
is_generic = not has_text and not has_desc
|
||||
is_structural = actual_intent in structural_intents
|
||||
|
||||
if is_generic:
|
||||
if is_generic or is_structural:
|
||||
reason = "generic semantic" if is_generic else "structural intent"
|
||||
logger.warning(
|
||||
f"⚠️ [Anti-Poisoning] Refusing to blacklist generic semantic: '{sem}'. "
|
||||
f"It would poison all similar nodes. Skipping blacklist for '{actual_intent}'."
|
||||
f"⚠️ [Anti-Poisoning] Refusing to blacklist {reason}: '{sem}'. "
|
||||
f"Skipping global blacklist, but purging poisoned cache for '{actual_intent}'."
|
||||
)
|
||||
TelepathicEngine._last_click_context = None
|
||||
return
|
||||
else:
|
||||
# Add to global blacklist
|
||||
if actual_intent not in self._blacklist:
|
||||
self._blacklist[actual_intent] = []
|
||||
if sem not in self._blacklist[actual_intent]:
|
||||
self._blacklist[actual_intent].append(sem)
|
||||
self._save_json(BLACKLIST_FILE, self._blacklist)
|
||||
logger.warning(f"🚫 [Negative Learning] Blacklisted: '{actual_intent}' → '{sem}'")
|
||||
|
||||
# Add to blacklist
|
||||
if actual_intent not in self._blacklist:
|
||||
self._blacklist[actual_intent] = []
|
||||
if sem not in self._blacklist[actual_intent]:
|
||||
self._blacklist[actual_intent].append(sem)
|
||||
self._save_json(BLACKLIST_FILE, self._blacklist)
|
||||
logger.warning(f"🚫 [Negative Learning] Blacklisted: '{actual_intent}' → '{sem}'")
|
||||
# ── Always Purge Positive Cache ──
|
||||
# Even if it's generic, if it failed for THIS specific intent, we MUST unlearn
|
||||
# the positive memory cache so we don't infinitely retry it.
|
||||
|
||||
# Remove from positive memory if it was cached
|
||||
if actual_intent in self._memory and sem in self._memory[actual_intent]:
|
||||
@@ -738,17 +803,25 @@ class TelepathicEngine:
|
||||
logger.warning("❌ [Semantic Verification] FAILED: Profile does not report 'Following' state.")
|
||||
return False
|
||||
|
||||
if any(k in low_intent for k in ["explore grid", "profile grid", "first image"]):
|
||||
if any(k in low_intent for k in ["explore grid", "profile grid", "first image", "grid item"]):
|
||||
# Clicking a grid item MUST open a post view.
|
||||
# Posts have feed markers. Reels have clips markers.
|
||||
feed_markers = [
|
||||
# Posts have feed markers. Reels/clips have their own markers.
|
||||
post_markers = [
|
||||
# Normal feed posts
|
||||
"row_feed_button_like", "row_feed_button_comment", "row_feed_button_share",
|
||||
"row_feed_comment_textview_layout", "row_feed_view_group",
|
||||
"clips_media_component", "row_feed_photo_profile_name", "row_feed_photo_imageview"
|
||||
"row_feed_photo_profile_name", "row_feed_photo_imageview",
|
||||
# Reels / Clips
|
||||
"clips_media_component", "clips_viewer", "clips_like_button",
|
||||
"clips_comment_button", "reel_viewer", "clips_music_attribution",
|
||||
# Carousel / Gallery
|
||||
"carousel_page_indicator", "media_set_page_indicator",
|
||||
# Generic post markers
|
||||
"action_bar_original_title", "media_header_user",
|
||||
]
|
||||
marker_found = any(m in low_xml for m in feed_markers)
|
||||
marker_found = any(m in low_xml for m in post_markers)
|
||||
if marker_found:
|
||||
logger.debug("✅ [Semantic Verification] Success confirmed: Post opened from grid (Feed markers detected).")
|
||||
logger.debug("✅ [Semantic Verification] Success confirmed: Post/Reel opened from grid.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("❌ [Semantic Verification] FAILED: Grid tap did not open a valid post view.")
|
||||
@@ -885,12 +958,25 @@ class TelepathicEngine:
|
||||
})
|
||||
return None
|
||||
|
||||
# ── Structural Guard 2: Position (status / nav bar) ──
|
||||
# ── Structural Guard 2: Position (status / nav bar / tab zones) ──
|
||||
if match.get("y", 0) < screen_height * STATUS_BAR_ZONE:
|
||||
logger.error(f"❌ [Structural Guard] VLM selected element in status bar zone: {match['semantic_string']}. REJECTING.")
|
||||
return None
|
||||
|
||||
is_nav_intent = any(k in intent.lower() for k in ["tab", "navigation", "search and explore", "reels", "profile", "home", "message"])
|
||||
|
||||
# NAVIGATION TAB ENFORCEMENT:
|
||||
# Real navigation tabs (Home, Search, Reels, Store, Profile) are ALWAYS in the bottom zone.
|
||||
# If the bot is looking for a tab, forbid results that are too high up (likely hallucinations on comments/feed).
|
||||
if is_nav_intent:
|
||||
# Tab intents MUST be in the bottom 10% (0.90) of the screen
|
||||
if match.get("y", 0) < screen_height * 0.90:
|
||||
logger.error(
|
||||
f"❌ [Structural Guard] VLM hallucinated a navigation tab '{intent}' "
|
||||
f"in the middle of the screen (Y={match.get('y')} | Screen={screen_height}). REJECTING."
|
||||
)
|
||||
return None
|
||||
|
||||
if match.get("y", 0) > screen_height * NAV_BAR_ZONE and not is_nav_intent:
|
||||
logger.error(f"❌ [Structural Guard] VLM selected element in nav bar zone for non-nav intent '{intent}': {match['semantic_string']}. REJECTING.")
|
||||
return None
|
||||
@@ -920,3 +1006,42 @@ class TelepathicEngine:
|
||||
logger.error(f"[Vision Cortex] Fallback failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _is_modal_active(self, nodes: list, raw_xml_string: str = "") -> bool:
|
||||
"""
|
||||
Detects if a dominant bottom sheet, dialog, or modal is covering the screen.
|
||||
Scans both semantic nodes and the raw XML hierarchy for markers.
|
||||
"""
|
||||
import re
|
||||
|
||||
modal_res_ids = {
|
||||
"com.instagram.android:id/bottom_sheet_container",
|
||||
"com.instagram.android:id/modal_container",
|
||||
"com.instagram.android:id/dialog_root",
|
||||
"com.instagram.android:id/message_box_container",
|
||||
"com.instagram.android:id/bottom_sheet_drag_handle",
|
||||
"com.instagram.android:id/bottom_sheet_container_view",
|
||||
"com.instagram.android:id/comment_composer_text_view"
|
||||
}
|
||||
|
||||
# 1. Structural Regex Check (Fastest and catches 'empty' or non-interactable modals)
|
||||
if raw_xml_string:
|
||||
# Look for any of the resource IDs in the raw XML string
|
||||
pattern = "|".join(modal_res_ids).replace(".", "\\.")
|
||||
if re.search(pattern, raw_xml_string):
|
||||
return True
|
||||
|
||||
# 2. Semantic Node Check (Iterative fallback)
|
||||
for n in nodes:
|
||||
# Direct resource-ID check
|
||||
rid = n.get("resource-id", "")
|
||||
if rid in modal_res_ids:
|
||||
# Double check that it's actually visible/large
|
||||
if n.get("visible", True) and n.get("area", 0) > 100000:
|
||||
return True
|
||||
|
||||
# Semantic check (e.g., "Comments" title in a sheet)
|
||||
if "bottom_sheet" in rid.lower() or "dialog" in rid.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -15,8 +15,8 @@ def _humanized_scroll_down(device):
|
||||
end_y = int(h * 0.2) + device.cm_to_pixels(random.uniform(-0.5, 0.5))
|
||||
duration = random.uniform(0.08, 0.12)
|
||||
device.deviceV2.swipe(start_x, start_y, start_x, end_y, duration)
|
||||
from GramAddict.core.bot_flow import sleep
|
||||
sleep(1.0)
|
||||
from GramAddict.core.utils import random_sleep
|
||||
random_sleep(0.8, 1.5)
|
||||
|
||||
def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
|
||||
"""
|
||||
@@ -32,7 +32,8 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
failed_scrolls = 0
|
||||
total_unfollowed_this_session = 0
|
||||
|
||||
from GramAddict.core.bot_flow import sleep, dump_ui_state, _humanized_click
|
||||
from GramAddict.core.bot_flow import dump_ui_state, _humanized_click
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
# Initialize basic tuple if it's missing (helps with tests and initializations)
|
||||
if not hasattr(session_state, 'totalUnfollowed'):
|
||||
@@ -53,7 +54,7 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
try:
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
# Use Telepathic Engine to explicitly locate existing "Following" buttons in lists
|
||||
nodes = telepathic._extract_semantic_nodes(xml_dump, "find 'Following' buttons next to usernames", threshold=0.7)
|
||||
@@ -70,14 +71,14 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
logger.debug(f"👆 Tapped following button at ({node['x']}, {node['y']})")
|
||||
|
||||
# Check for confirmation dialog ("Unfollow @username?")
|
||||
sleep(1.5)
|
||||
confirm_xml = device.deviceV2.dump_hierarchy()
|
||||
random_sleep(1.0, 2.0)
|
||||
confirm_xml = device.dump_hierarchy()
|
||||
confirm_nodes = telepathic._extract_semantic_nodes(confirm_xml, "find 'Unfollow' confirmation button", threshold=0.8)
|
||||
|
||||
if confirm_nodes and not confirm_nodes[0].get("skip"):
|
||||
c_node = confirm_nodes[0]
|
||||
_humanized_click(device, c_node["x"], c_node["y"])
|
||||
sleep(1.0)
|
||||
random_sleep(0.8, 1.5)
|
||||
|
||||
logger.info("✅ [Unfollow Engine] Unfollowed a user in list.", extra={"color": Fore.GREEN})
|
||||
session_state.totalUnfollowed += 1
|
||||
@@ -86,7 +87,7 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
|
||||
# Unfollow cost logic
|
||||
dopamine.boredom += random.uniform(1.0, 3.0)
|
||||
sleep(2.0)
|
||||
random_sleep(1.5, 3.0)
|
||||
break
|
||||
|
||||
if not action_taken:
|
||||
|
||||
Reference in New Issue
Block a user