feat: complete modular plugin refactor with 100% E2E coverage for interactions
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""Human-like Instagram bot powered by UIAutomator2"""
|
||||
|
||||
from GramAddict.core.version import __version__, __tested_ig_version__
|
||||
|
||||
from GramAddict.core.bot_flow import start_bot
|
||||
from GramAddict.core.version import __tested_ig_version__, __version__
|
||||
|
||||
|
||||
def run(**kwargs):
|
||||
start_bot(**kwargs)
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def verify_and_switch_account(device, nav_graph, target_username):
|
||||
logger.info(f"🛂 [Identity Guard] Verifying if active account matches target: '{target_username}'")
|
||||
|
||||
|
||||
# 1. Navigate to OwnProfile to reliably check identity
|
||||
success = nav_graph.navigate_to("OwnProfile", zero_engine=None)
|
||||
if not success:
|
||||
logger.error("❌ [Identity Guard] Failed to reach OwnProfile to verify account.")
|
||||
return False
|
||||
|
||||
|
||||
time.sleep(2.0)
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
|
||||
# 2. Check if already active
|
||||
# The action_bar_title on OwnProfile contains the username.
|
||||
is_active = False
|
||||
@@ -31,34 +32,35 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing XML for identity check: {e}")
|
||||
|
||||
|
||||
if is_active:
|
||||
logger.info(f"✅ [Identity Guard] Successfully verified active account is already '{target_username}'.")
|
||||
return True
|
||||
|
||||
|
||||
logger.warning(f"🔄 [Identity Guard] Account mismatch detected! Switching to '{target_username}'...")
|
||||
|
||||
|
||||
# 3. Find the Profile Tab to long press using Telepathic Engine (Blank Start)
|
||||
profile_tab = None
|
||||
try:
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
|
||||
|
||||
# We ask the semantic engine to find the profile tab, ensuring 100% ID-agnostic behavior
|
||||
profile_tab_node = telepath.find_best_node(xml_dump, "tap profile tab", min_threshold=0.3)
|
||||
if profile_tab_node:
|
||||
profile_tab = (profile_tab_node["x"], profile_tab_node["y"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Error resolving profile tab via telepathic engine: {e}")
|
||||
|
||||
|
||||
if not profile_tab:
|
||||
logger.error("❌ [Identity Guard] Cannot find profile_tab semantically to initiate account switch!")
|
||||
return False
|
||||
|
||||
|
||||
# Long press to open account selector
|
||||
device.long_click(profile_tab[0], profile_tab[1], 1.5)
|
||||
time.sleep(3.0)
|
||||
|
||||
|
||||
# 4. Find the target account in the selector list
|
||||
xml_dump = device.dump_hierarchy()
|
||||
account_node = None
|
||||
@@ -68,11 +70,11 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
for elem in root.iter("node"):
|
||||
text = elem.attrib.get("text", "").lower()
|
||||
content_desc = elem.attrib.get("content-desc", "").lower()
|
||||
|
||||
|
||||
# Exact match or starts with username followed by spaces/punctuation
|
||||
target_l = target_username.lower()
|
||||
is_match = False
|
||||
|
||||
|
||||
if text == target_l or content_desc == target_l:
|
||||
is_match = True
|
||||
elif target_l in text.split() or target_l in content_desc.split():
|
||||
@@ -82,7 +84,7 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
elif target_l in text or target_l in content_desc:
|
||||
# Fallback purely to literal inclusion (might match backups, but better than failing)
|
||||
is_match = True
|
||||
|
||||
|
||||
if is_match:
|
||||
bounds_str = elem.attrib.get("bounds")
|
||||
if bounds_str:
|
||||
@@ -94,21 +96,25 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if account_node:
|
||||
logger.info(f"🖱️ [Identity Guard] Found account '{target_username}' in selector. Tapping!")
|
||||
device.click(account_node[0], account_node[1])
|
||||
time.sleep(6.0) # Wait heavily for app to reload context
|
||||
nav_graph.current_state = "UNKNOWN" # Force graph to re-evaluate after massive state shift
|
||||
time.sleep(6.0) # Wait heavily for app to reload context
|
||||
nav_graph.current_state = "UNKNOWN" # Force graph to re-evaluate after massive state shift
|
||||
return True
|
||||
else:
|
||||
logger.error(f"❌ [Identity Guard] Target account '{target_username}' not found in the account switcher! Is it logged in?")
|
||||
logger.error(
|
||||
f"❌ [Identity Guard] Target account '{target_username}' not found in the account switcher! Is it logged in?"
|
||||
)
|
||||
try:
|
||||
from GramAddict.core.diagnostic_dump import dump_ui_state
|
||||
dump_ui_state(device, "identity_guard", {"reason": "account_not_found_in_bottom_sheet", "target": target_username})
|
||||
|
||||
dump_ui_state(
|
||||
device, "identity_guard", {"reason": "account_not_found_in_bottom_sheet", "target": target_username}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
# Escape the bottom sheet
|
||||
device.press("back")
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ v2 Enhancements:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import math
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,14 +24,15 @@ logger = logging.getLogger(__name__)
|
||||
class ActiveInferenceEngine:
|
||||
"""
|
||||
Bayesian Active Inference Engine.
|
||||
Calculates Free Energy (Surprise) based on prediction errors in the
|
||||
Calculates Free Energy (Surprise) based on prediction errors in the
|
||||
Instagram environment. Steers the agent's 'Thermodynamic Policy'.
|
||||
|
||||
|
||||
Policies:
|
||||
- STABLE: Free energy < 0.75. Normal operation. All interactions enabled.
|
||||
- CAUTIOUS: Free energy 0.75-1.2. Reduced interaction probability. Longer waits.
|
||||
- DORMANT: Free energy > 1.2. Minimal interactions. Maximum sleep. May recommend abort.
|
||||
"""
|
||||
|
||||
def __init__(self, username):
|
||||
self.username = username
|
||||
self.free_energy = 0.0
|
||||
@@ -39,30 +40,30 @@ class ActiveInferenceEngine:
|
||||
self.last_update = time.time()
|
||||
self.policy = "STABLE" # STABLE, CAUTIOUS, DORMANT
|
||||
self.expectation_history = []
|
||||
|
||||
|
||||
# v2: Consecutive error tracking for escalation
|
||||
self._consecutive_prediction_errors = 0
|
||||
self._total_predictions = 0
|
||||
self._total_errors = 0
|
||||
self._session_start = time.time()
|
||||
|
||||
|
||||
def calculate_surprise(self, predicted_outcome: float, observed_outcome: float):
|
||||
"""
|
||||
Bayesian surprise calculation (simplified Kullback-Leibler divergence).
|
||||
"""
|
||||
# prediction error
|
||||
error = abs(predicted_outcome - observed_outcome)
|
||||
|
||||
|
||||
# Free energy accumulation
|
||||
self.free_energy = (self.free_energy * 0.7) + (error * 0.3)
|
||||
|
||||
|
||||
# Decay free energy over time (Thermodynamic relaxation)
|
||||
now = time.time()
|
||||
hours_passed = (now - self.last_update) / 3600.0
|
||||
decay = math.exp(-0.1 * hours_passed)
|
||||
self.free_energy *= decay
|
||||
self.last_update = now
|
||||
|
||||
|
||||
# Policy steering
|
||||
if self.free_energy > 1.2:
|
||||
self.policy = "DORMANT"
|
||||
@@ -70,8 +71,11 @@ class ActiveInferenceEngine:
|
||||
self.policy = "CAUTIOUS"
|
||||
else:
|
||||
self.policy = "STABLE"
|
||||
|
||||
logger.info(f"⚖️ [Active Inference] Surprise: {self.free_energy:.4f} | Policy: {self.policy}", extra={"color": f"{Fore.BLUE}"})
|
||||
|
||||
logger.info(
|
||||
f"⚖️ [Active Inference] Surprise: {self.free_energy:.4f} | Policy: {self.policy}",
|
||||
extra={"color": f"{Fore.BLUE}"},
|
||||
)
|
||||
return self.free_energy
|
||||
|
||||
def predict_state(self, expected_signature: list):
|
||||
@@ -80,22 +84,24 @@ class ActiveInferenceEngine:
|
||||
expected_signature: list of terms expected in the resulting XML.
|
||||
"""
|
||||
self.expectation_history.append(expected_signature)
|
||||
logger.debug(f"⚖️ [Shadow Mode] Predicting future state containing: {expected_signature}", extra={"color": f"{Fore.BLUE}"})
|
||||
logger.debug(
|
||||
f"⚖️ [Shadow Mode] Predicting future state containing: {expected_signature}", extra={"color": f"{Fore.BLUE}"}
|
||||
)
|
||||
|
||||
def evaluate_prediction(self, context_xml: str) -> bool:
|
||||
"""
|
||||
Evaluates the last prediction against reality.
|
||||
Returns True if reality matches prediction, False otherwise (Prediction Error).
|
||||
|
||||
|
||||
v2: Tracks consecutive errors and escalates policy automatically.
|
||||
"""
|
||||
if not self.expectation_history:
|
||||
return True
|
||||
|
||||
|
||||
expected_signature = self.expectation_history.pop()
|
||||
self._total_predictions += 1
|
||||
matched = any(sig.lower() in context_xml.lower() for sig in expected_signature)
|
||||
|
||||
|
||||
if matched:
|
||||
self._consecutive_prediction_errors = 0
|
||||
self.calculate_surprise(1.0, 1.0)
|
||||
@@ -103,42 +109,46 @@ class ActiveInferenceEngine:
|
||||
else:
|
||||
self._consecutive_prediction_errors += 1
|
||||
self._total_errors += 1
|
||||
logger.warning(f"⚖️ [Shadow Mode] Prediction Error #{self._consecutive_prediction_errors}! "
|
||||
f"Did not find {expected_signature} in resulting UI.", extra={"color": f"{Fore.RED}"})
|
||||
logger.warning(
|
||||
f"⚖️ [Shadow Mode] Prediction Error #{self._consecutive_prediction_errors}! "
|
||||
f"Did not find {expected_signature} in resulting UI.",
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
self.calculate_surprise(1.0, 0.0)
|
||||
|
||||
|
||||
# v2: Consecutive error escalation
|
||||
if self._consecutive_prediction_errors >= 5:
|
||||
self.policy = "DORMANT"
|
||||
logger.error(
|
||||
f"🚨 [Active Inference] {self._consecutive_prediction_errors} consecutive prediction errors! "
|
||||
f"Environment is fundamentally unstable. DORMANT mode engaged.",
|
||||
extra={"color": f"{Fore.RED}"}
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
elif self._consecutive_prediction_errors >= 3:
|
||||
self.policy = "CAUTIOUS"
|
||||
logger.warning(
|
||||
f"⚠️ [Active Inference] {self._consecutive_prediction_errors} consecutive errors. "
|
||||
f"Switching to CAUTIOUS policy.",
|
||||
extra={"color": f"{Fore.YELLOW}"}
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
|
||||
|
||||
# ── Dojo Data Engine Hook ──
|
||||
# When prediction fails, explicitly submit the snapshot for shadow-compilation
|
||||
try:
|
||||
from GramAddict.core.dojo_engine import DojoEngine
|
||||
|
||||
# Note: get_instance() works without passing device as it was already initialized in bot_flow by this point.
|
||||
dojo = DojoEngine.get_instance()
|
||||
dojo.submit_snapshot(
|
||||
heuristic_name=str(expected_signature),
|
||||
context_xml=context_xml,
|
||||
intent_prompt=f"Locate the missing elements or correct the heuristic predicting state: {expected_signature}"
|
||||
intent_prompt=f"Locate the missing elements or correct the heuristic predicting state: {expected_signature}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to offload snapshot to Dojo Engine: {e}")
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_sleep_modifier(self):
|
||||
"""
|
||||
Returns a multiplier for sleep durations based on surprise.
|
||||
@@ -156,11 +166,11 @@ class ActiveInferenceEngine:
|
||||
def get_interaction_probability(self) -> float:
|
||||
"""
|
||||
Returns a probability multiplier [0.0 - 1.0] for interaction decisions.
|
||||
|
||||
|
||||
Under STABLE: 1.0 (full interaction rate)
|
||||
Under CAUTIOUS: 0.5 (halved interaction rate)
|
||||
Under DORMANT: 0.1 (minimal interaction — only high-confidence targets)
|
||||
|
||||
|
||||
This directly modifies follow/like/comment probability in the feed loop.
|
||||
"""
|
||||
if self.policy == "DORMANT":
|
||||
@@ -172,11 +182,11 @@ class ActiveInferenceEngine:
|
||||
def should_abort_session(self) -> bool:
|
||||
"""
|
||||
Recommends session abort when the environment is fundamentally broken.
|
||||
|
||||
|
||||
Triggers:
|
||||
- 5+ consecutive prediction errors (UI is completely unexpected)
|
||||
- Free energy > 2.0 (accumulated instability beyond recovery)
|
||||
|
||||
|
||||
The caller (bot_flow) can choose to honor this or override.
|
||||
"""
|
||||
if self._consecutive_prediction_errors >= 5:
|
||||
|
||||
@@ -18,7 +18,7 @@ Tesla analogy: Instead of one "drive" function, there are composable behaviors
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,15 +29,17 @@ class BehaviorContext:
|
||||
Shared context passed to every behavior plugin.
|
||||
Contains everything a behavior needs to make decisions and act.
|
||||
"""
|
||||
device: Any # Android device facade
|
||||
configs: Any # User configuration
|
||||
session_state: Any # Current session state
|
||||
cognitive_stack: Dict[str, Any] # Cognitive engines (growth, resonance, etc.)
|
||||
context_xml: str = "" # Current screen XML dump
|
||||
sleep_mod: float = 1.0 # Active Inference sleep multiplier
|
||||
post_data: Optional[Dict] = None # Extracted post content
|
||||
username: str = "" # Current target username (if applicable)
|
||||
|
||||
|
||||
device: Any # Android device facade
|
||||
configs: Any # User configuration
|
||||
session_state: Any # Current session state
|
||||
cognitive_stack: Dict[str, Any] # Cognitive engines (growth, resonance, etc.)
|
||||
shared_state: Dict[str, Any] = field(default_factory=dict) # State shared between plugins
|
||||
context_xml: str = "" # Current screen XML dump
|
||||
sleep_mod: float = 1.0 # Active Inference sleep multiplier
|
||||
post_data: Optional[Dict] = None # Extracted post content
|
||||
username: str = "" # Current target username (if applicable)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BehaviorResult:
|
||||
@@ -45,39 +47,40 @@ class BehaviorResult:
|
||||
Result returned by a behavior plugin after execution.
|
||||
Used by the orchestrator to decide what happens next.
|
||||
"""
|
||||
executed: bool = False # Did the behavior actually do something?
|
||||
should_continue: bool = True # Should the feed loop continue to next post?
|
||||
should_skip: bool = False # Should we skip to the next post immediately?
|
||||
interactions: int = 0 # Number of interactions performed
|
||||
|
||||
executed: bool = False # Did the behavior actually do something?
|
||||
should_continue: bool = True # Should the feed loop continue to next post?
|
||||
should_skip: bool = False # Should we skip to the next post immediately?
|
||||
interactions: int = 0 # Number of interactions performed
|
||||
metadata: Dict[str, Any] = field(default_factory=dict) # Plugin-specific data
|
||||
|
||||
|
||||
class BehaviorPlugin(ABC):
|
||||
"""
|
||||
Base class for all behavior plugins.
|
||||
|
||||
|
||||
Lifecycle:
|
||||
1. `can_activate(ctx)` — Should this behavior fire for this context?
|
||||
2. `priority` — If multiple behaviors can activate, higher priority goes first.
|
||||
3. `execute(ctx)` — Run the behavior.
|
||||
|
||||
|
||||
Rules:
|
||||
- Plugins must be stateless between posts (state lives in session_state)
|
||||
- Plugins must handle their own errors (never crash the feed loop)
|
||||
- Plugins must respect session limits via ctx.session_state
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for this behavior."""
|
||||
...
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
"""
|
||||
Execution priority. Higher = runs first.
|
||||
|
||||
|
||||
Guidelines:
|
||||
- 100+: Safety/guard behaviors (ad detection, block detection)
|
||||
- 50-99: Primary interactions (like, follow, comment)
|
||||
@@ -85,7 +88,7 @@ class BehaviorPlugin(ABC):
|
||||
- 1-9: Observational behaviors (scraping, analytics)
|
||||
"""
|
||||
return 50
|
||||
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
@@ -93,7 +96,7 @@ class BehaviorPlugin(ABC):
|
||||
Used for guard behaviors that abort interaction (e.g., ad detection).
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""
|
||||
@@ -101,7 +104,7 @@ class BehaviorPlugin(ABC):
|
||||
Must be cheap to evaluate (no device interactions).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""
|
||||
@@ -109,7 +112,11 @@ class BehaviorPlugin(ABC):
|
||||
Returns a BehaviorResult describing what happened.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def get_config(self, ctx: BehaviorContext) -> dict:
|
||||
"""Helper to retrieve plugin-specific configuration."""
|
||||
return ctx.configs.get_plugin_config(self.name)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} name={self.name} priority={self.priority}>"
|
||||
|
||||
@@ -117,27 +124,28 @@ class BehaviorPlugin(ABC):
|
||||
class PluginRegistry:
|
||||
"""
|
||||
Central registry for behavior plugins.
|
||||
|
||||
|
||||
Manages plugin registration, priority sorting, and orchestrated execution.
|
||||
Thread-safe singleton.
|
||||
"""
|
||||
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "PluginRegistry":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
"""Wipe the registry singleton instance."""
|
||||
cls._instance = None
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self._plugins: List[BehaviorPlugin] = []
|
||||
self._sorted = False
|
||||
|
||||
|
||||
def register(self, plugin: BehaviorPlugin):
|
||||
"""Register a behavior plugin."""
|
||||
# Prevent duplicate registration
|
||||
@@ -145,28 +153,28 @@ class PluginRegistry:
|
||||
if existing.name == plugin.name:
|
||||
logger.debug(f"Plugin '{plugin.name}' already registered. Skipping.")
|
||||
return
|
||||
|
||||
|
||||
self._plugins.append(plugin)
|
||||
self._sorted = False
|
||||
logger.info(f"🧩 [Plugin] Registered: {plugin.name} (priority={plugin.priority})")
|
||||
|
||||
|
||||
def unregister(self, name: str):
|
||||
"""Remove a plugin by name."""
|
||||
self._plugins = [p for p in self._plugins if p.name != name]
|
||||
self._sorted = False
|
||||
|
||||
|
||||
def _ensure_sorted(self):
|
||||
"""Sort plugins by priority (highest first)."""
|
||||
if not self._sorted:
|
||||
self._plugins.sort(key=lambda p: p.priority, reverse=True)
|
||||
self._sorted = True
|
||||
|
||||
|
||||
@property
|
||||
def plugins(self) -> List[BehaviorPlugin]:
|
||||
"""Returns all plugins, sorted by priority."""
|
||||
self._ensure_sorted()
|
||||
return list(self._plugins)
|
||||
|
||||
|
||||
def get_active_plugins(self, ctx: BehaviorContext) -> List[BehaviorPlugin]:
|
||||
"""Returns plugins that can activate for the given context, sorted by priority."""
|
||||
self._ensure_sorted()
|
||||
@@ -178,38 +186,66 @@ class PluginRegistry:
|
||||
except Exception as e:
|
||||
logger.error(f"🧩 [Plugin] Error checking {plugin.name}.can_activate: {e}")
|
||||
return active
|
||||
|
||||
|
||||
def execute_all(self, ctx: BehaviorContext) -> List[BehaviorResult]:
|
||||
"""
|
||||
Execute all active plugins in priority order.
|
||||
|
||||
|
||||
Stops early if an exclusive plugin fires (e.g., ad guard).
|
||||
Returns list of results from all executed plugins.
|
||||
"""
|
||||
self._ensure_sorted()
|
||||
results = []
|
||||
|
||||
|
||||
for plugin in self._plugins:
|
||||
try:
|
||||
if not plugin.can_activate(ctx):
|
||||
continue
|
||||
|
||||
logger.debug(f"🧩 [Plugin] Executing: {plugin.name}")
|
||||
|
||||
logger.debug(f"🧩 [PluginRegistry] TRACE: Calling execute() on {plugin.name}")
|
||||
result = plugin.execute(ctx)
|
||||
results.append(result)
|
||||
|
||||
if plugin.exclusive and result.executed:
|
||||
logger.debug(f"🧩 [Plugin] {plugin.name} is exclusive. Stopping chain.")
|
||||
|
||||
if (plugin.exclusive and result.executed) or result.should_skip:
|
||||
logger.debug(
|
||||
f"🧩 [Plugin] {plugin.name} triggered chain termination (exclusive={plugin.exclusive}, should_skip={result.should_skip})."
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"🧩 [Plugin] Error executing {plugin.name}: {e}")
|
||||
results.append(BehaviorResult(executed=False, metadata={"error": str(e)}))
|
||||
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def __len__(self):
|
||||
return len(self._plugins)
|
||||
|
||||
|
||||
def __contains__(self, name: str):
|
||||
return any(p.name == name for p in self._plugins)
|
||||
|
||||
|
||||
# Import plugins at the bottom to avoid circular imports
|
||||
from GramAddict.core.behaviors.ad_guard import AdGuardPlugin as AdGuardPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.anomaly_handler import AnomalyHandlerPlugin as AnomalyHandlerPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.close_friends_guard import (
|
||||
CloseFriendsGuardPlugin as CloseFriendsGuardPlugin, # noqa: E402
|
||||
)
|
||||
from GramAddict.core.behaviors.comment import CommentPlugin as CommentPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.darwin_dwell import DarwinDwellPlugin as DarwinDwellPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.like import LikePlugin as LikePlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin as ObstacleGuardPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.perfect_snapping import PerfectSnappingPlugin as PerfectSnappingPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.post_data_extraction import (
|
||||
PostDataExtractionPlugin as PostDataExtractionPlugin, # noqa: E402
|
||||
)
|
||||
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin as PostInteractionPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin as ProfileVisitPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin as RabbitHolePlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.repost import RepostPlugin as RepostPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.resonance_evaluator import (
|
||||
ResonanceEvaluatorPlugin as ResonanceEvaluatorPlugin, # noqa: E402
|
||||
)
|
||||
|
||||
# Note: We do not automatically instantiate all of them globally here to avoid circular
|
||||
# dependencies during initial load. The bot_flow.py engine should explicitly register them.
|
||||
|
||||
74
GramAddict/core/behaviors/ad_guard.py
Normal file
74
GramAddict/core/behaviors/ad_guard.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdGuardPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Checks for ads in the feed and scrolls past them.
|
||||
Implements a deadlock escape after 5 consecutive ads.
|
||||
|
||||
Priority: 100 (Safety guard, runs first).
|
||||
Exclusive: True (if ad detected, stop other interactions).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
self.consecutive_ads = 0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "ad_guard"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 100
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
# We check for ad presence here to decide if we activate.
|
||||
# This is a bit more expensive than a percentage check but necessary for a guard.
|
||||
# Optimization: Only check if context_xml is available or do a quick string search.
|
||||
if ctx.context_xml:
|
||||
return is_ad(ctx.context_xml, ctx.cognitive_stack)
|
||||
|
||||
return False
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
self.consecutive_ads += 1
|
||||
|
||||
if self.consecutive_ads >= 5:
|
||||
logger.warning("🛡️ [AdGuard] Deadlock detected: 5 consecutive ads. Escaping to HomeFeed.")
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
zero_engine = ctx.cognitive_stack.get("zero_latency_engine")
|
||||
if nav_graph:
|
||||
nav_graph.navigate_to("HomeFeed", zero_engine)
|
||||
self.consecutive_ads = 0
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
logger.info(f"🛡️ [AdGuard] Ad detected ({self.consecutive_ads}). Scrolling past it...")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
|
||||
# Aggressive double skip for triple ad
|
||||
if self.consecutive_ads >= 3:
|
||||
logger.info("🛡️ [AdGuard] Aggressive skip for consecutive ads.")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
def reset_counter(self):
|
||||
self.consecutive_ads = 0
|
||||
48
GramAddict/core/behaviors/anomaly_handler.py
Normal file
48
GramAddict/core/behaviors/anomaly_handler.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnomalyHandlerPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles anomalies like zero interactive nodes on screen.
|
||||
|
||||
Priority: 98 (Runs after AdGuard, before others).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "anomaly_handler"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 98
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
nodes = telepathic._extract_semantic_nodes(xml)
|
||||
|
||||
ctx.shared_state["interactive_nodes"] = nodes
|
||||
|
||||
if len(nodes) == 0:
|
||||
logger.warning("🚨 [Anomaly] Zero interactive nodes found. Executing recovery...")
|
||||
ctx.device.press("back")
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
humanized_scroll(ctx.device)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.perception.feed_analysis import has_carousel_in_view
|
||||
from GramAddict.core.physics.humanized_input import humanized_horizontal_swipe
|
||||
|
||||
@@ -19,85 +19,69 @@ logger = logging.getLogger(__name__)
|
||||
class CarouselBrowsingPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Browses carousel posts with humanized swiping and curiosity dwells.
|
||||
|
||||
Activation: When a carousel indicator is present in the current XML.
|
||||
Priority: 20 (secondary interaction — runs after primary like/follow decisions).
|
||||
|
||||
Priority: 70 (Primary interaction).
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "carousel_browsing"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 20 # Secondary interaction tier
|
||||
|
||||
return 70
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when carousel indicators are present on screen."""
|
||||
if not ctx.context_xml:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
# Check config — carousel_percentage controls activation probability
|
||||
carousel_pct = float(getattr(ctx.configs.args, "carousel_percentage", 0)) / 100.0
|
||||
if carousel_pct <= 0:
|
||||
|
||||
# Analysis requires XML
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
if not has_carousel_in_view(xml):
|
||||
return False
|
||||
return has_carousel_in_view(ctx.context_xml)
|
||||
|
||||
|
||||
config = self.get_config(ctx)
|
||||
percentage = float(config.get("percentage", getattr(ctx.configs.args, "carousel_percentage", 0)))
|
||||
return random.random() < (percentage / 100.0)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Browse carousel with humanized swiping."""
|
||||
from colorama import Fore
|
||||
|
||||
carousel_pct = float(getattr(ctx.configs.args, "carousel_percentage", 0)) / 100.0
|
||||
|
||||
# Probabilistic execution (config controls how often we interact)
|
||||
if random.random() >= carousel_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
# Parse swipe count from config
|
||||
carousel_count_str = getattr(ctx.configs.args, "carousel_count", "1-2")
|
||||
|
||||
config = self.get_config(ctx)
|
||||
carousel_count_str = config.get("count", getattr(ctx.configs.args, "carousel_count", "1-2"))
|
||||
try:
|
||||
min_c, max_c = map(int, carousel_count_str.split('-'))
|
||||
min_c, max_c = map(int, carousel_count_str.split("-"))
|
||||
count = random.randint(min_c, max_c)
|
||||
except Exception:
|
||||
count = 1
|
||||
|
||||
|
||||
logger.info(
|
||||
f"📸 [Carousel] Interacting with carousel. Swiping {count} times...",
|
||||
extra={"color": f"{Fore.CYAN}"}
|
||||
f"📸 [Carousel] Interacting with carousel. Swiping {count} times...", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
|
||||
|
||||
info = ctx.device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
|
||||
# Curiosity Peak: One slide 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) * ctx.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}. "
|
||||
f"Gazing for {dwell:.1f}s..."
|
||||
)
|
||||
logger.debug(f"📸 [Carousel] Curiosity Peak hit on slide {i+1}. " f"Gazing for {dwell:.1f}s...")
|
||||
sleep(dwell * ctx.sleep_mod)
|
||||
|
||||
|
||||
# Horizontal swipe: Right to left
|
||||
humanized_horizontal_swipe(
|
||||
ctx.device,
|
||||
start_x=w * 0.8,
|
||||
end_x=w * 0.2,
|
||||
y=h * 0.5,
|
||||
duration_ms=250
|
||||
)
|
||||
|
||||
humanized_horizontal_swipe(ctx.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) * ctx.sleep_mod)
|
||||
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=count,
|
||||
metadata={"slides_viewed": count, "curiosity_slide": curiosity_slide}
|
||||
executed=True, interactions=count, metadata={"slides_viewed": count, "curiosity_slide": curiosity_slide}
|
||||
)
|
||||
|
||||
44
GramAddict/core/behaviors/close_friends_guard.py
Normal file
44
GramAddict/core/behaviors/close_friends_guard.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloseFriendsGuardPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Checks for close friends badge and skips.
|
||||
|
||||
Priority: 99.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "close_friends_guard"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 99
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
return "enge freunde" in xml.lower() or "close friend" in xml.lower()
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.info("💚 [CloseFriendsGuard] Close friends post detected. Skipping...")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
85
GramAddict/core/behaviors/comment.py
Normal file
85
GramAddict/core/behaviors/comment.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommentPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles commenting on posts.
|
||||
|
||||
Priority: 55.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "comment"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 55
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should comment on this post."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.COMMENTS):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
comment_pct = float(config.get("percentage", getattr(ctx.configs.args, "comment_percentage", 0))) / 100.0
|
||||
|
||||
if comment_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate (includes resonance weighting if available in shared_state)
|
||||
res_score = ctx.shared_state.get("res_score", 1.0)
|
||||
chance = comment_pct * res_score
|
||||
|
||||
if random.random() >= chance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Comment on the current post."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
config = self.get_config(ctx)
|
||||
|
||||
# 1. Open comment section
|
||||
if nav_graph.do("open comments"):
|
||||
# 2. Generate comment text
|
||||
writer = ctx.cognitive_stack.get("writer")
|
||||
if not writer:
|
||||
logger.warning("✍️ [Comment] No 'writer' found in cognitive stack. Cannot generate comment.")
|
||||
ctx.device.press("back")
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
text = writer.generate_comment(ctx.post_data)
|
||||
logger.info(f"✍️ [Comment] Generated: '{text}'")
|
||||
|
||||
# 3. Handle Dry Run
|
||||
if config.get("dry_run", getattr(ctx.configs.args, "dry_run_comments", False)):
|
||||
logger.info("🧪 [Comment] Dry run enabled. Skipping actual post.")
|
||||
ctx.device.press("back")
|
||||
return BehaviorResult(executed=True, interactions=0, metadata={"text": text, "dry_run": True})
|
||||
|
||||
# 4. Type and post
|
||||
if nav_graph.do("type and post comment", text=text):
|
||||
logger.info(f"💬 [Comment] Posted to @{ctx.username} ✓")
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, liked=False)
|
||||
ctx.session_state.totalComments += 1
|
||||
return BehaviorResult(executed=True, interactions=1, metadata={"text": text})
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
48
GramAddict/core/behaviors/darwin_dwell.py
Normal file
48
GramAddict/core/behaviors/darwin_dwell.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DarwinDwellPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Simulates human dwelling using the Darwin engine.
|
||||
|
||||
Priority: 60 (Runs after evaluation, before interactions).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "darwin_dwell"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 60
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
percentage = float(config.get("percentage", 100))
|
||||
return random.random() < (percentage / 100.0)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
darwin = ctx.cognitive_stack.get("darwin")
|
||||
if darwin:
|
||||
logger.info("🐢 [DarwinDwell] Executing organic dwell behaviors...")
|
||||
darwin.execute_micro_wobble(ctx.device)
|
||||
res_score = ctx.shared_state.get("res_score", 1.0)
|
||||
darwin.execute_proof_of_resonance(ctx.device, res_score)
|
||||
else:
|
||||
logger.info("🐢 [DarwinDwell] Darwin engine missing. Falling back to static sleep.")
|
||||
sleep(2.5 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(executed=True)
|
||||
@@ -1,73 +1,61 @@
|
||||
"""
|
||||
Follow Behavior — Plugin Implementation.
|
||||
|
||||
Follows a target user's profile with session limit awareness.
|
||||
Migrated from the follow section of _interact_with_profile.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FollowPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Follows a target user from their profile page.
|
||||
|
||||
Activation: When follow_percentage > 0 and session follow limit not reached.
|
||||
Priority: 60 (primary interaction tier).
|
||||
Follows a target user from their profile page or feed.
|
||||
|
||||
Priority: 40.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "follow"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 60 # Primary interaction tier
|
||||
|
||||
return 40
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when follow is enabled and limits not reached."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
follow_pct = float(getattr(ctx.configs.args, "follow_percentage", 0)) / 100.0
|
||||
|
||||
config = self.get_config(ctx)
|
||||
follow_pct = float(config.get("percentage", getattr(ctx.configs.args, "follow_percentage", 0))) / 100.0
|
||||
|
||||
if follow_pct <= 0:
|
||||
return False
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.FOLLOWS):
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= follow_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Follow the target user."""
|
||||
follow_pct = float(getattr(ctx.configs.args, "follow_percentage", 0)) / 100.0
|
||||
|
||||
rnd = random.random()
|
||||
logger.info(
|
||||
f"⚙️ [Decision] Profile Follow -> Config: {follow_pct*100}% "
|
||||
f"(Roll: {rnd:.2f}) -> Proceed: {rnd < follow_pct}"
|
||||
)
|
||||
|
||||
if rnd >= follow_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("tap follow button"):
|
||||
logger.info(f"🤝 [Follow] Followed @{ctx.username} ✓")
|
||||
ctx.session_state.totalFollowed[ctx.username] = 1
|
||||
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=True, liked=False)
|
||||
|
||||
# Buffer for follow animations to close
|
||||
sleep(random.uniform(1.8, 3.2) * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=1,
|
||||
metadata={"followed": ctx.username}
|
||||
)
|
||||
|
||||
|
||||
return BehaviorResult(executed=True, interactions=1, metadata={"followed": ctx.username})
|
||||
|
||||
return BehaviorResult(executed=False, metadata={"reason": "nav_failed"})
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"""
|
||||
Grid Like Behavior — Plugin Implementation.
|
||||
|
||||
Likes posts from a target user's profile grid.
|
||||
Migrated from the grid-likes section of _interact_with_profile.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_click, humanized_scroll
|
||||
from GramAddict.core.physics.timing import wait_for_post_loaded
|
||||
|
||||
@@ -19,108 +12,101 @@ logger = logging.getLogger(__name__)
|
||||
class GridLikePlugin(BehaviorPlugin):
|
||||
"""
|
||||
Opens profile grid and likes posts with humanized behavior.
|
||||
|
||||
Activation: When likes_percentage > 0 and session like limit not reached.
|
||||
Priority: 50 (primary interaction tier, after follow).
|
||||
|
||||
Priority: 30.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "grid_like"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 50 # Primary interaction, after follow
|
||||
|
||||
return 30
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when likes are enabled, limits not reached, and we are on a profile."""
|
||||
"""Activates when likes are enabled, limits not reached, and probability met."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
likes_pct = float(getattr(ctx.configs.args, "likes_percentage", 0)) / 100.0
|
||||
|
||||
config = self.get_config(ctx)
|
||||
likes_pct = float(config.get("percentage", getattr(ctx.configs.args, "likes_percentage", 0))) / 100.0
|
||||
|
||||
if likes_pct <= 0:
|
||||
return False
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.LIKES):
|
||||
return False
|
||||
|
||||
|
||||
# ── STRUCTURAL GUARD ──
|
||||
# Prevent execution in Reels/HomeFeed. Must be on a profile.
|
||||
if ctx.context_xml:
|
||||
if "profile_header" not in ctx.context_xml.lower() and "followers" not in ctx.context_xml.lower():
|
||||
return False
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if nav_graph and nav_graph.current_state != "ProfileView":
|
||||
return False
|
||||
|
||||
# Fallback XML check
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
xml_lower = xml.lower()
|
||||
if "followers" not in xml_lower and "beiträge" not in xml_lower and "posts" not in xml_lower:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= likes_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Open grid and like posts."""
|
||||
likes_pct = float(getattr(ctx.configs.args, "likes_percentage", 0)) / 100.0
|
||||
|
||||
rnd = random.random()
|
||||
logger.info(
|
||||
f"⚙️ [Decision] Profile Grid Likes -> Config: {likes_pct*100}% "
|
||||
f"(Roll: {rnd:.2f}) -> Proceed: {rnd < likes_pct}"
|
||||
)
|
||||
|
||||
if rnd >= likes_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
config = self.get_config(ctx)
|
||||
|
||||
# Parse like count
|
||||
likes_count_str = getattr(ctx.configs.args, "likes_count", "1-2")
|
||||
likes_count_str = config.get("count", getattr(ctx.configs.args, "likes_count", "1-2"))
|
||||
try:
|
||||
min_l, max_l = map(int, likes_count_str.split('-'))
|
||||
count = random.randint(min_l, max_l)
|
||||
if "-" in likes_count_str:
|
||||
min_l, max_l = map(int, likes_count_str.split("-"))
|
||||
count = random.randint(min_l, max_l)
|
||||
else:
|
||||
count = int(likes_count_str)
|
||||
except Exception:
|
||||
count = 1
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if not nav_graph.do("tap first image post in profile grid"):
|
||||
return BehaviorResult(executed=False, metadata={"reason": "grid_nav_failed"})
|
||||
|
||||
|
||||
if not wait_for_post_loaded(ctx.device, timeout=5):
|
||||
logger.warning(f"❌ Post failed to open from profile grid of @{ctx.username}.")
|
||||
logger.warning(f"❌ [GridLike] Post failed to open from profile grid of @{ctx.username}.")
|
||||
return BehaviorResult(executed=False, metadata={"reason": "post_load_failed"})
|
||||
|
||||
logger.info(
|
||||
f"❤️ [Grid Like] Opening grid to drop {count} likes on @{ctx.username}..."
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"❤️ [GridLike] Dropping {count} likes on @{ctx.username} profile grid...")
|
||||
|
||||
info = ctx.device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
|
||||
growth = ctx.cognitive_stack.get("growth_brain")
|
||||
total_liked = 0
|
||||
|
||||
|
||||
for i in range(count):
|
||||
xml_dump = ctx.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
|
||||
)
|
||||
|
||||
# Double-tap ~40% of the time on standard images
|
||||
|
||||
# Use growth brain for decision making (double tap vs heart button)
|
||||
use_double_tap = growth.wants_to_double_tap(is_reel=is_reel) if growth else False
|
||||
|
||||
|
||||
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"❤️ [Grid Like] Double-Tapping organically at ({offset_x}, {offset_y})"
|
||||
)
|
||||
humanized_click(ctx.device, offset_x, offset_y, double=True, sleep_mod=ctx.sleep_mod)
|
||||
ctx.session_state.totalLikes += 1
|
||||
total_liked += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Double-Tap")
|
||||
offset_x = random.randint(int(w * 0.2), int(w * 0.8))
|
||||
offset_y = random.randint(int(h * 0.3), int(h * 0.7))
|
||||
humanized_click(ctx.device, offset_x, offset_y, double=True, sleep_mod=ctx.sleep_mod)
|
||||
ctx.session_state.totalLikes += 1
|
||||
total_liked += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Double-Tap")
|
||||
else:
|
||||
if nav_graph.do("tap like button"):
|
||||
ctx.session_state.totalLikes += 1
|
||||
@@ -128,22 +114,19 @@ class GridLikePlugin(BehaviorPlugin):
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Heart Button")
|
||||
else:
|
||||
logger.debug(f"Skipped liking grid post {i+1}/{count}")
|
||||
|
||||
|
||||
sleep(random.uniform(1.0, 2.0) * ctx.sleep_mod)
|
||||
|
||||
if is_reel:
|
||||
logger.debug("🎬 Detected Reel. Swiping full-screen up.")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
else:
|
||||
humanized_scroll(ctx.device, is_skip=False)
|
||||
|
||||
sleep(random.uniform(1.5, 3.0) * ctx.sleep_mod)
|
||||
|
||||
|
||||
if i < count - 1:
|
||||
if is_reel:
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
else:
|
||||
humanized_scroll(ctx.device, is_skip=False)
|
||||
sleep(random.uniform(1.5, 3.0) * ctx.sleep_mod)
|
||||
|
||||
ctx.device.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * ctx.sleep_mod)
|
||||
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=total_liked,
|
||||
metadata={"posts_viewed": count, "posts_liked": total_liked}
|
||||
executed=True, interactions=total_liked, metadata={"posts_viewed": count, "posts_liked": total_liked}
|
||||
)
|
||||
|
||||
60
GramAddict/core/behaviors/like.py
Normal file
60
GramAddict/core/behaviors/like.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LikePlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles liking posts.
|
||||
|
||||
Priority: 50.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "likes"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 50
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should like this post."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.LIKES):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
likes_pct = float(config.get("percentage", getattr(ctx.configs.args, "likes_percentage", 80))) / 100.0
|
||||
|
||||
if likes_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= likes_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Like the current post."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("tap like button"):
|
||||
logger.info(f"❤️ [Like] Liked post by @{ctx.username} ✓")
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, liked=True)
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
81
GramAddict/core/behaviors/obstacle_guard.py
Normal file
81
GramAddict/core/behaviors/obstacle_guard.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.diagnostic_dump import dump_ui_state
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObstacleGuardPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Guards against modals and checks marker presence to prevent infinite loops.
|
||||
|
||||
Priority: 95.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "obstacle_guard"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 95
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
sae = SituationalAwarenessEngine.get_instance(ctx.device)
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
situation = sae.perceive(xml)
|
||||
|
||||
misses = ctx.shared_state.get("consecutive_marker_misses", 0)
|
||||
|
||||
if situation == SituationType.OBSTACLE_MODAL:
|
||||
if misses >= 2:
|
||||
logger.error("🛑 [ObstacleGuard] Failed to recover from OBSTACLE_MODAL after multiple attempts.")
|
||||
sae.unlearn_current_state()
|
||||
dump_ui_state(ctx.device, f"fatal_obstacle_{ctx.session_state.job_target}")
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"return_code": "CONTEXT_LOST"})
|
||||
|
||||
logger.warning("⚠️ [ObstacleGuard] OBSTACLE_MODAL detected. Attempting to dismiss...")
|
||||
ctx.device.press("back")
|
||||
sleep(1.5 * ctx.sleep_mod)
|
||||
|
||||
# Check recovery
|
||||
new_xml = ctx.device.dump_hierarchy()
|
||||
tele = TelepathicEngine.get_instance()
|
||||
best_node = tele.find_best_node(new_xml, intent_description="Dismiss obstacle")
|
||||
if best_node:
|
||||
ctx.device.click(best_node.get("x", 0), best_node.get("y", 0))
|
||||
|
||||
if "row_feed_button_like" in new_xml:
|
||||
logger.info("✅ [ObstacleGuard] Successfully recovered from OBSTACLE_MODAL.")
|
||||
ctx.shared_state["consecutive_marker_misses"] = 0
|
||||
else:
|
||||
ctx.shared_state["consecutive_marker_misses"] = misses + 1
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=True) # Restart loop for same post or next
|
||||
|
||||
else: # SituationType.NORMAL
|
||||
if "row_feed_button_like" not in xml:
|
||||
logger.info("🧩 [ObstacleGuard] Missing feed markers. Scrolling...")
|
||||
ctx.shared_state["consecutive_marker_misses"] = misses + 1
|
||||
humanized_scroll(ctx.device)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
else:
|
||||
ctx.shared_state["consecutive_marker_misses"] = 0
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
42
GramAddict/core/behaviors/perfect_snapping.py
Normal file
42
GramAddict/core/behaviors/perfect_snapping.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.bot_flow import _align_active_post
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PerfectSnappingPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Aligns the current post in the viewport.
|
||||
|
||||
Priority: 90 (Runs after guards, before extraction).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "perfect_snapping"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 90
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
aligned = _align_active_post(ctx.device)
|
||||
if aligned:
|
||||
logger.info("🎯 [PerfectSnapping] Post aligned. Refreshing context XML...")
|
||||
new_xml = ctx.device.dump_hierarchy()
|
||||
radome = ctx.cognitive_stack.get("radome")
|
||||
if radome:
|
||||
new_xml = radome.sanitize_xml(new_xml)
|
||||
ctx.context_xml = new_xml
|
||||
return BehaviorResult(executed=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
41
GramAddict/core/behaviors/post_data_extraction.py
Normal file
41
GramAddict/core/behaviors/post_data_extraction.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.perception.feed_analysis import extract_post_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostDataExtractionPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Extracts post data (caption, hashtags, user) for later evaluation.
|
||||
|
||||
Priority: 85 (Runs after guards, before evaluation).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "post_data_extraction"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 85
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True) and ctx.context_xml is not None
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.debug("🧩 [PostDataExtraction] Extracting post metadata...")
|
||||
post_data = extract_post_content(ctx.context_xml)
|
||||
|
||||
if post_data:
|
||||
ctx.post_data = post_data
|
||||
ctx.username = post_data.get("username", "")
|
||||
logger.info(f"📝 [PostDataExtraction] Post by @{ctx.username} extracted.")
|
||||
return BehaviorResult(executed=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
51
GramAddict/core/behaviors/post_interaction.py
Normal file
51
GramAddict/core/behaviors/post_interaction.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostInteractionPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Runs after all interactions on a post are complete.
|
||||
Handles scrolling to the next post and logging outcomes.
|
||||
|
||||
Priority: 10 (lowest, runs last).
|
||||
Exclusive: True (ends the behavior chain for this post).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "post_interaction"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 10 # Lowest priority, runs last
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True # Ends the behavior chain for this post
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.info("🏁 [PostInteraction] Interactions complete. Moving to next post...")
|
||||
|
||||
# Log to CRM or telemetry if active
|
||||
telemetry = ctx.cognitive_stack.get("telemetry")
|
||||
if telemetry:
|
||||
telemetry.log_post_interaction(ctx.post_data, ctx.shared_state.get("session_outcomes", []))
|
||||
|
||||
humanized_scroll(ctx.device)
|
||||
sleep(1.5 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True, should_skip=True
|
||||
) # should_skip=True signals the feed loop to restart for the next post
|
||||
@@ -12,7 +12,7 @@ Priority 100 (highest, exclusive) — if a guard fires, no other behavior runs.
|
||||
|
||||
import logging
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,80 +22,65 @@ class ProfileGuardPlugin(BehaviorPlugin):
|
||||
Guards against interacting with profiles that should be skipped.
|
||||
Exclusive: if this fires, no further interactions happen on this profile.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "profile_guard"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 100 # Highest — runs before everything
|
||||
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True # Stop all other plugins if guard fires
|
||||
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Only activates on Profile screens to prevent false-positives in Feed/Reels."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
is_profile = nav_graph and nav_graph.current_state == "ProfileView"
|
||||
return bool(ctx.username) and is_profile
|
||||
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Check profile guards. Returns executed=True + should_skip=True if rejected."""
|
||||
from colorama import Fore
|
||||
|
||||
|
||||
xml_check = ctx.context_xml
|
||||
if not xml_check:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
|
||||
xml_check_lower = xml_check.lower()
|
||||
|
||||
|
||||
# Self-interaction guard
|
||||
if (hasattr(ctx.session_state, 'my_username') and
|
||||
ctx.username == ctx.session_state.my_username):
|
||||
logger.info(
|
||||
f"🤝 [Profile Guard] Skipping own profile @{ctx.username}."
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "self_profile"})
|
||||
|
||||
if hasattr(ctx.session_state, "my_username") and ctx.username == ctx.session_state.my_username:
|
||||
logger.info(f"🤝 [Profile Guard] Skipping own profile @{ctx.username}.")
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "self_profile"})
|
||||
|
||||
# Private account guard
|
||||
if ("this account is private" in xml_check_lower or
|
||||
"konto ist privat" in xml_check_lower):
|
||||
logger.info(
|
||||
f"🔒 [Profile Guard] @{ctx.username} is private.",
|
||||
extra={"color": f"{Fore.YELLOW}"}
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "private"})
|
||||
|
||||
if "this account is private" in xml_check_lower or "konto ist privat" in xml_check_lower:
|
||||
logger.info(f"🔒 [Profile Guard] @{ctx.username} is private.", extra={"color": f"{Fore.YELLOW}"})
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "private"})
|
||||
|
||||
# Empty account guard
|
||||
if ("no posts yet" in xml_check_lower or
|
||||
"noch keine beiträge" in xml_check_lower):
|
||||
logger.info(
|
||||
f"📭 [Profile Guard] @{ctx.username} has no posts.",
|
||||
extra={"color": f"{Fore.YELLOW}"}
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "empty"})
|
||||
|
||||
if "no posts yet" in xml_check_lower or "noch keine beiträge" in xml_check_lower:
|
||||
logger.info(f"📭 [Profile Guard] @{ctx.username} has no posts.", extra={"color": f"{Fore.YELLOW}"})
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "empty"})
|
||||
|
||||
# Close friends guard
|
||||
if getattr(ctx.configs.args, "ignore_close_friends", False):
|
||||
if ("enge freunde" in xml_check_lower or
|
||||
"close friend" in xml_check_lower):
|
||||
if "enge freunde" in xml_check_lower or "close friend" in xml_check_lower:
|
||||
logger.info(
|
||||
f"💚 [Profile Guard] @{ctx.username} is a Close Friend. Ignoring.",
|
||||
extra={"color": "\033[32m"}
|
||||
f"💚 [Profile Guard] @{ctx.username} is a Close Friend. Ignoring.", extra={"color": "\033[32m"}
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "close_friend"})
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "close_friend"})
|
||||
|
||||
# Visual Vibe Check (AI Aesthetic Quality Guard)
|
||||
import random
|
||||
|
||||
vibe_check_pct = float(getattr(ctx.configs.args, "visual_vibe_check_percentage", 0)) / 100.0
|
||||
if vibe_check_pct > 0 and random.random() < vibe_check_pct:
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepathic = ctx.cognitive_stack.get("telepathic") or TelepathicEngine.get_instance()
|
||||
persona_interests = ctx.cognitive_stack.get("persona_interests", []) if ctx.cognitive_stack else []
|
||||
vibe_result = telepathic.evaluate_profile_vibe(ctx.device, persona_interests)
|
||||
@@ -107,13 +92,14 @@ class ProfileGuardPlugin(BehaviorPlugin):
|
||||
logger.warning(
|
||||
f"🚫 [Vibe Check] Profile @{ctx.username} rejected (Score: {score}, Niche: {matches_niche}). Reason: {vibe_result.get('reason')}"
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "vibe_check_failed", "score": score})
|
||||
return BehaviorResult(
|
||||
executed=True, should_skip=True, metadata={"reason": "vibe_check_failed", "score": score}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"✅ [Vibe Check] Profile @{ctx.username} approved (Score: {score}). Continuing interaction.",
|
||||
extra={"color": "\033[36m"}
|
||||
extra={"color": "\033[36m"},
|
||||
)
|
||||
|
||||
|
||||
# All guards passed — don't block further plugins
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
102
GramAddict/core/behaviors/profile_visit.py
Normal file
102
GramAddict/core/behaviors/profile_visit.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProfileVisitPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles visiting a user's profile from the feed.
|
||||
|
||||
Priority: 35.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "profile_visit"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 35
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should visit the profile."""
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
# 1. Guard against recursive calls or being already on profile
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if nav_graph and nav_graph.current_state == "ProfileView":
|
||||
return False
|
||||
|
||||
# 2. Probability gate
|
||||
config = self.get_config(ctx)
|
||||
visit_pct = float(config.get("percentage", getattr(ctx.configs.args, "profile_visit_percentage", 30))) / 100.0
|
||||
|
||||
if visit_pct <= 0:
|
||||
return False
|
||||
|
||||
# 3. Probability gate (weighted by resonance)
|
||||
res_score = ctx.shared_state.get("res_score", 1.0)
|
||||
chance = visit_pct * res_score
|
||||
|
||||
if random.random() >= chance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Visit the user's profile and execute nested plugins."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("tap post username"):
|
||||
logger.info(f"👤 [ProfileVisit] Visiting @{ctx.username}...")
|
||||
sleep(2.0 * ctx.sleep_mod)
|
||||
|
||||
# Create a new context for the profile interaction
|
||||
from GramAddict.core.behaviors import BehaviorContext, PluginRegistry
|
||||
|
||||
# Update nav state to ProfileView
|
||||
original_state = nav_graph.current_state
|
||||
nav_graph.current_state = "ProfileView"
|
||||
|
||||
profile_xml = ctx.device.dump_hierarchy()
|
||||
profile_ctx = BehaviorContext(
|
||||
device=ctx.device,
|
||||
configs=ctx.configs,
|
||||
session_state=ctx.session_state,
|
||||
cognitive_stack=ctx.cognitive_stack,
|
||||
context_xml=profile_xml,
|
||||
sleep_mod=ctx.sleep_mod,
|
||||
post_data=ctx.post_data,
|
||||
username=ctx.username,
|
||||
shared_state=ctx.shared_state,
|
||||
)
|
||||
|
||||
logger.info(f"🕵️ [ProfileVisit] Executing interactions on @{ctx.username}'s profile...")
|
||||
registry = PluginRegistry.get_instance()
|
||||
|
||||
# Execute all active plugins on the profile view (including ProfileGuard)
|
||||
registry.execute_all(profile_ctx)
|
||||
|
||||
# Restore nav state
|
||||
nav_graph.current_state = original_state
|
||||
|
||||
logger.info(f"🔙 [ProfileVisit] Returning from @{ctx.username}.")
|
||||
ctx.device.press("back")
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
53
GramAddict/core/behaviors/rabbit_hole.py
Normal file
53
GramAddict/core/behaviors/rabbit_hole.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RabbitHolePlugin(BehaviorPlugin):
|
||||
"""
|
||||
Randomly jumps into a user's profile if resonance is high.
|
||||
|
||||
Priority: 20 (Secondary interaction).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "rabbit_hole"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 20
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
res_score = ctx.shared_state.get("res_score", 0.0)
|
||||
if res_score < 0.8:
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
percentage = float(config.get("percentage", 15))
|
||||
return random.random() < (percentage / 100.0)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.info("🕳️ [RabbitHole] Falling down the rabbit hole! Investigating user profile...")
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if nav_graph:
|
||||
success = nav_graph.do("tap post username")
|
||||
if success:
|
||||
sleep(2.0 * ctx.sleep_mod)
|
||||
# Just a quick peek
|
||||
ctx.device.press("back")
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
57
GramAddict/core/behaviors/repost.py
Normal file
57
GramAddict/core/behaviors/repost.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RepostPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles reposting (sharing to story) for posts.
|
||||
|
||||
Priority: 45.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "repost"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 45
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should repost this post."""
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
repost_pct = float(config.get("percentage", getattr(ctx.configs.args, "repost_percentage", 20))) / 100.0
|
||||
|
||||
if repost_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= repost_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Repost the current post."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("share to story"):
|
||||
logger.info(f"📤 [Repost] Shared post by @{ctx.username} to story ✓")
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
85
GramAddict/core/behaviors/resonance_evaluator.py
Normal file
85
GramAddict/core/behaviors/resonance_evaluator.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResonanceEvaluatorPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Evaluates how much the bot likes a post based on its descriptions, vibes, etc.
|
||||
Decides whether to proceed with interactions or skip the post.
|
||||
|
||||
Priority: 80 (Runs after data extraction).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "resonance_evaluator"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 80
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
resonance = ctx.cognitive_stack.get("resonance")
|
||||
if not resonance:
|
||||
logger.warning("🧠 [Resonance] Engine missing. Defaulting to 1.0")
|
||||
res_score = 1.0
|
||||
else:
|
||||
post_data = ctx.post_data or {}
|
||||
res_score = resonance.calculate_resonance(post_data)
|
||||
|
||||
# Check visual vibe
|
||||
config = self.get_config(ctx)
|
||||
visual_chance = float(
|
||||
config.get("visual_vibe_check_percentage", getattr(ctx.configs.args, "visual_vibe_check_percentage", 0))
|
||||
)
|
||||
if visual_chance > 0 and random.random() < (visual_chance / 100.0):
|
||||
tele = ctx.cognitive_stack.get("telepathic")
|
||||
if tele:
|
||||
logger.info("✨ [Resonance] Performing visual vibe check...")
|
||||
vibe = tele.evaluate_post_vibe()
|
||||
vibe_score = vibe.get("quality_score", 5) / 10.0
|
||||
if vibe.get("matches_niche"):
|
||||
vibe_score = min(1.0, vibe_score + 0.2)
|
||||
res_score = (res_score * 0.3) + (vibe_score * 0.7)
|
||||
|
||||
ctx.shared_state["res_score"] = res_score
|
||||
logger.info(f"📊 [Resonance] Post Score: {res_score:.2f}")
|
||||
|
||||
interact_chance = float(getattr(ctx.configs.args, "interact_percentage", 100))
|
||||
|
||||
# Determine if we should skip the entire post
|
||||
# Threshold could be dynamic, but let's say 0.2 is the floor for absolute garbage
|
||||
if res_score < 0.2 or random.random() >= (interact_chance / 100.0):
|
||||
logger.info(f"⏭️ [Resonance] Skipping post (score={res_score:.2f}, chance check failed).")
|
||||
|
||||
if "session_outcomes" not in ctx.shared_state:
|
||||
ctx.shared_state["session_outcomes"] = []
|
||||
|
||||
ctx.shared_state["session_outcomes"].append(
|
||||
{"username": ctx.username, "resonance": res_score, "action": "skip"}
|
||||
)
|
||||
|
||||
# If we skip here, we MUST scroll to next post and terminate chain
|
||||
humanized_scroll(ctx.device)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
dopamine = ctx.cognitive_stack.get("dopamine")
|
||||
if dopamine:
|
||||
quality = "high" if res_score > 0.7 else ("medium" if res_score > 0.4 else "low")
|
||||
dopamine.process_content({"score": res_score * 10, "quality": quality})
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=False)
|
||||
@@ -1,15 +1,8 @@
|
||||
"""
|
||||
Story Viewing Behavior — Plugin Implementation.
|
||||
|
||||
Watches a target user's stories with humanized timing and navigation.
|
||||
Migrated from the story-viewing section of _interact_with_profile.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_click
|
||||
from GramAddict.core.physics.timing import wait_for_story_loaded
|
||||
|
||||
@@ -19,83 +12,87 @@ logger = logging.getLogger(__name__)
|
||||
class StoryViewPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Views a target user's stories from their profile.
|
||||
|
||||
Activation: When stories_percentage > 0 and user has stories.
|
||||
Priority: 40 (runs before likes/follows since it navigates away from profile).
|
||||
|
||||
Priority: 25.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "story_view"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 40 # Before likes/follows (since it navigates away)
|
||||
|
||||
return 25
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when story viewing is enabled in config."""
|
||||
stories_pct = float(getattr(ctx.configs.args, "stories_percentage", 0)) / 100.0
|
||||
return stories_pct > 0
|
||||
|
||||
"""Activates when story viewing is enabled and probability met."""
|
||||
config = self.get_config(ctx)
|
||||
stories_pct = float(config.get("percentage", getattr(ctx.configs.args, "stories_percentage", 0))) / 100.0
|
||||
|
||||
if stories_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= stories_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""View stories with humanized timing."""
|
||||
from colorama import Fore
|
||||
|
||||
stories_pct = float(getattr(ctx.configs.args, "stories_percentage", 0)) / 100.0
|
||||
|
||||
# Probabilistic check
|
||||
if random.random() >= stories_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
config = self.get_config(ctx)
|
||||
|
||||
# Parse story count
|
||||
stories_count_str = getattr(ctx.configs.args, "stories_count", "1-2")
|
||||
stories_count_str = config.get("count", getattr(ctx.configs.args, "stories_count", "1-2"))
|
||||
try:
|
||||
min_st, max_st = map(int, stories_count_str.split('-'))
|
||||
count = random.randint(min_st, max_st)
|
||||
if "-" in stories_count_str:
|
||||
min_st, max_st = map(int, stories_count_str.split("-"))
|
||||
count = random.randint(min_st, max_st)
|
||||
else:
|
||||
count = int(stories_count_str)
|
||||
except Exception:
|
||||
count = 1
|
||||
|
||||
|
||||
# Check for story ring
|
||||
xml_dump = ctx.context_xml or ctx.device.dump_hierarchy()
|
||||
xml_lower = xml_dump.lower()
|
||||
xml = ctx.context_xml or ctx.device.dump_hierarchy()
|
||||
xml_lower = xml.lower()
|
||||
has_story = (
|
||||
"reel_ring" in xml_dump or
|
||||
"'s unseen story" in xml_lower or
|
||||
"has a new story" in xml_lower or
|
||||
"story von" in xml_lower
|
||||
"reel_ring" in xml
|
||||
or "has an unseen story" in xml_lower
|
||||
or "has a new story" in xml_lower
|
||||
or "story von" in xml_lower
|
||||
)
|
||||
|
||||
|
||||
if not has_story:
|
||||
return BehaviorResult(executed=False, metadata={"reason": "no_story"})
|
||||
|
||||
|
||||
# Navigate to story
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if not nav_graph.do("tap story ring avatar"):
|
||||
return BehaviorResult(executed=False, metadata={"reason": "nav_failed"})
|
||||
|
||||
|
||||
# Wait for story to load
|
||||
if not wait_for_story_loaded(ctx.device, timeout=5):
|
||||
logger.warning(f"❌ Story failed to open for @{ctx.username}.")
|
||||
logger.warning(f"❌ [StoryView] Story failed to open for @{ctx.username}.")
|
||||
return BehaviorResult(executed=False, metadata={"reason": "load_timeout"})
|
||||
|
||||
logger.info(f"📸 [Story] Viewing @{ctx.username}'s story ({count} times)...")
|
||||
|
||||
|
||||
logger.info(f"📸 [StoryView] Viewing @{ctx.username}'s story ({count} segments)...")
|
||||
|
||||
info = ctx.device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
|
||||
for i in range(count):
|
||||
sleep(random.uniform(2.0, 5.0) * ctx.sleep_mod)
|
||||
if i < count - 1:
|
||||
humanized_click(ctx.device, int(w * 0.9), int(h * 0.5), sleep_mod=ctx.sleep_mod)
|
||||
|
||||
|
||||
ctx.device.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=count,
|
||||
metadata={"stories_viewed": count}
|
||||
)
|
||||
|
||||
return BehaviorResult(executed=True, interactions=count, metadata={"stories_viewed": count})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BENCHMARKS_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "benchmarks", "data", "llm_benchmarks.json")
|
||||
BENCHMARKS_FILE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "benchmarks", "data", "llm_benchmarks.json"
|
||||
)
|
||||
|
||||
|
||||
def check_model_benchmarks(configs):
|
||||
"""
|
||||
@@ -27,17 +31,17 @@ def check_model_benchmarks(configs):
|
||||
def _eval_model(model_name: str, context: str):
|
||||
if not model_name:
|
||||
return
|
||||
|
||||
|
||||
if model_name not in benchmarks:
|
||||
logger.warning(
|
||||
f"⚠️ [Benchmark Guard] Model '{model_name}' (for {context}) is COMPLETELY UNTESTED "
|
||||
f"for the Agent. Expect severe hallucinations or crashed agents.",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"},
|
||||
)
|
||||
return
|
||||
|
||||
scores = benchmarks[model_name]
|
||||
|
||||
|
||||
# Telepathic/Vision tasks require high structural strictness
|
||||
if context == "Vision/Telepathic":
|
||||
score = scores.get("telepathic_score", 0)
|
||||
@@ -48,29 +52,29 @@ def check_model_benchmarks(configs):
|
||||
logger.error(
|
||||
f"⛔ [Benchmark Guard] Model '{model_name}' (for {context}) achieved a CRITICAL FAILURE score "
|
||||
f"of {score}/100. Autonomous safety is compromised. DO NOT RUN UNATTENDED.",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"},
|
||||
)
|
||||
elif score < 80:
|
||||
logger.warning(
|
||||
f"⚠️ [Benchmark Guard] Model '{model_name}' (for {context}) achieved a SUB-STANDARD score "
|
||||
f"of {score}/100. It may occasionally hallucinate UI elements or misinterpret semantics.",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"✅ [Benchmark Guard] Model '{model_name}' (for {context}) passes safety benchmarks ({score}/100).",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
# Which models did the user configure?
|
||||
telepathic_model = getattr(configs.args, "ai_telepathic_model", None)
|
||||
text_model = getattr(configs.args, "ai_model", None)
|
||||
condenser_model = getattr(configs.args, "ai_condenser_model", None)
|
||||
|
||||
|
||||
_eval_model(telepathic_model, "Vision/Telepathic")
|
||||
|
||||
|
||||
if text_model and text_model != telepathic_model:
|
||||
_eval_model(text_model, "Dopamine/Resonance")
|
||||
|
||||
|
||||
if condenser_model and condenser_model != text_model and condenser_model != telepathic_model:
|
||||
_eval_model(condenser_model, "Context Condensation")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import logging
|
||||
import json
|
||||
from io import BytesIO
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VLMCompilerEngine:
|
||||
"""
|
||||
The Self-Compiling Heuristics Engine
|
||||
@@ -11,6 +11,7 @@ class VLMCompilerEngine:
|
||||
It takes a screenshot + XML dump, finds the missing intent, and generates a new,
|
||||
blazing-fast deterministic Regex/XPath rule to be cached and executed next time.
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
|
||||
@@ -23,18 +24,26 @@ class VLMCompilerEngine:
|
||||
clean_intent = intent_description
|
||||
if "['" in clean_intent:
|
||||
clean_intent = clean_intent.replace("['", "").replace("']", "").replace("', '", " AND ")
|
||||
|
||||
logger.warning(f"🧠 [Compiler Engine] Deterministic heuristic failed for: '{clean_intent}'. Synthesizing new rule...", extra={"color": "\x1b[1m\x1b[35m"})
|
||||
|
||||
|
||||
logger.warning(
|
||||
f"🧠 [Compiler Engine] Deterministic heuristic failed for: '{clean_intent}'. Synthesizing new rule...",
|
||||
extra={"color": "\x1b[1m\x1b[35m"},
|
||||
)
|
||||
|
||||
args = getattr(self.device, "args", None)
|
||||
model = getattr(args, "ai_telepathic_model", "llama3.2:1b") if args else "llama3.2:1b"
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate") if args else "http://localhost:11434/api/generate"
|
||||
url = (
|
||||
getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
if args
|
||||
else "http://localhost:11434/api/generate"
|
||||
)
|
||||
use_local = "11434" in url or "localhost" in url
|
||||
|
||||
simplified_xml = self._simplify_xml(context_xml)
|
||||
|
||||
# --- Model Trust Logging ---
|
||||
from GramAddict.core.benchmark_guard import BENCHMARKS_FILE
|
||||
|
||||
trust_log = f"Using {model}"
|
||||
try:
|
||||
if os.path.exists(BENCHMARKS_FILE):
|
||||
@@ -44,7 +53,13 @@ class VLMCompilerEngine:
|
||||
score = bench_data.get("telepathic_score", 0)
|
||||
passed = "PASS" if bench_data.get("passed_all", False) else "FAIL"
|
||||
unsuitable = bench_data.get("is_unsuitable", False)
|
||||
trust_level = "HIGH" if score >= 80 and not unsuitable else "MEDIUM" if score >= 50 and not unsuitable else "LOW/UNSAFE"
|
||||
trust_level = (
|
||||
"HIGH"
|
||||
if score >= 80 and not unsuitable
|
||||
else "MEDIUM"
|
||||
if score >= 50 and not unsuitable
|
||||
else "LOW/UNSAFE"
|
||||
)
|
||||
trust_log += f" [Benchmark: {score}/100 | {passed} | Trust: {trust_level}]"
|
||||
if unsuitable:
|
||||
logger.error(f"⛔ [Safety Alert] {model} is marked as UNSUITABLE for this task!")
|
||||
@@ -58,37 +73,38 @@ class VLMCompilerEngine:
|
||||
"Rules:\n"
|
||||
"1. Output ONLY a raw JSON object.\n"
|
||||
"2. NO markdown, NO triple backticks.\n"
|
||||
"3. Format: {\"rule_type\": \"regex\", \"target_attribute\": \"resource-id\", \"pattern\": \".*regex.*\", \"confidence\": 0.95, \"reasoning\": \"string\"}"
|
||||
'3. Format: {"rule_type": "regex", "target_attribute": "resource-id", "pattern": ".*regex.*", "confidence": 0.95, "reasoning": "string"}'
|
||||
)
|
||||
|
||||
user_prompt = f"TARGET INTENT: {clean_intent}\n\nUI XML:\n{simplified_xml[:2000]}"
|
||||
|
||||
try:
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
res_text = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
temperature=0.1,
|
||||
use_local_edge=use_local
|
||||
use_local_edge=use_local,
|
||||
)
|
||||
|
||||
|
||||
if not res_text:
|
||||
logger.error("Compiler LLM returned empty response.")
|
||||
return None
|
||||
|
||||
|
||||
if "```json" in res_text:
|
||||
res_text = res_text.split("```json")[1].split("```")[0].strip()
|
||||
elif res_text.startswith("```"):
|
||||
res_text = "\n".join(res_text.strip().split("\n")[1:-1])
|
||||
|
||||
|
||||
try:
|
||||
decision = json.loads(res_text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Compiler LLM returned invalid JSON: {res_text[:100]}...")
|
||||
return None
|
||||
|
||||
|
||||
# If LLM returned a list, take the first item if it's a dict
|
||||
if isinstance(decision, list):
|
||||
if len(decision) > 0 and isinstance(decision[0], dict):
|
||||
@@ -96,26 +112,31 @@ class VLMCompilerEngine:
|
||||
else:
|
||||
logger.error(f"Compiler LLM returned unexpected list format: {decision}")
|
||||
return None
|
||||
|
||||
|
||||
if not isinstance(decision, dict):
|
||||
logger.error(f"Compiler LLM returned non-object response: {type(decision)}")
|
||||
return None
|
||||
|
||||
pattern = decision.get('pattern')
|
||||
|
||||
pattern = decision.get("pattern")
|
||||
if not pattern:
|
||||
logger.error("Compiler LLM returned empty rule pattern. Aborting heuristic generation.")
|
||||
return None
|
||||
|
||||
logger.info(f"✨ [Compiler] New Heuristic Synthesized! Rule: {decision.get('rule_type')} -> {pattern}", extra={"color": "\x1b[1m\x1b[32m"})
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✨ [Compiler] New Heuristic Synthesized! Rule: {decision.get('rule_type')} -> {pattern}",
|
||||
extra={"color": "\x1b[1m\x1b[32m"},
|
||||
)
|
||||
|
||||
if decision.get("rule_type") == "xpath":
|
||||
logger.error("Compiler LLM returned 'xpath'. Rejecting rule because it causes xml.etree crashes. Will fallback/retry.")
|
||||
logger.error(
|
||||
"Compiler LLM returned 'xpath'. Rejecting rule because it causes xml.etree crashes. Will fallback/retry."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
return {
|
||||
"rule_type": "regex",
|
||||
"target_attribute": decision.get("target_attribute", "text"),
|
||||
"pattern": pattern
|
||||
"pattern": pattern,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -124,6 +145,7 @@ class VLMCompilerEngine:
|
||||
|
||||
def _simplify_xml(self, xml_tree: str) -> str:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
nodes = []
|
||||
try:
|
||||
root = ET.fromstring(xml_tree)
|
||||
|
||||
@@ -99,9 +99,7 @@ class Config:
|
||||
|
||||
# Configure ArgParse
|
||||
self.parser = configargparse.ArgumentParser(
|
||||
config_file_open_func=lambda filename: open(
|
||||
filename, "r+", encoding="utf-8"
|
||||
),
|
||||
config_file_open_func=lambda filename: open(filename, "r+", encoding="utf-8"),
|
||||
description="GramAddict Instagram Bot",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
@@ -129,7 +127,7 @@ class Config:
|
||||
action="store_true",
|
||||
help="Enable Tesla E2E Vision 'Shadow Mode' Telemetry daemon.",
|
||||
)
|
||||
|
||||
|
||||
# Core Singularity Jobs
|
||||
self.parser.add_argument("--feed", help="Amount of feed posts to interact with", default=None)
|
||||
self.parser.add_argument("--explore", help="Amount of explore posts to interact with", default=None)
|
||||
@@ -141,14 +139,18 @@ class Config:
|
||||
self.parser.add_argument("--time-delta-session", help="Time delta between sessions", default=None)
|
||||
self.parser.add_argument("--restart-atx-agent", action="store_true", help="Restart atx agent")
|
||||
self.parser.add_argument("--allow-untested-ig-version", action="store_true", help="Allow untested IG version")
|
||||
self.parser.add_argument("--blank-start", action="store_true", help="Wipe all learned navigation and telepathic memories on boot to start 100% blank.")
|
||||
self.parser.add_argument(
|
||||
"--blank-start",
|
||||
action="store_true",
|
||||
help="Wipe all learned navigation and telepathic memories on boot to start 100% blank.",
|
||||
)
|
||||
|
||||
# Interaction settings
|
||||
self.parser.add_argument("--likes-count", help="Likes count", default="2-3")
|
||||
self.parser.add_argument("--likes-percentage", help="Likes percentage", default="100")
|
||||
self.parser.add_argument("--stories-count", help="Stories count", default="0")
|
||||
self.parser.add_argument("--stories-percentage", help="Stories percentage", default="0")
|
||||
|
||||
|
||||
# Total Limits (Legacy names preserved for SessionState compatibility)
|
||||
self.parser.add_argument("--total-likes-limit", help="Total likes limit", default="300")
|
||||
self.parser.add_argument("--total-follows-limit", help="Total follows limit", default="50")
|
||||
@@ -156,53 +158,137 @@ class Config:
|
||||
self.parser.add_argument("--total-comments-limit", help="Total comments limit", default="10")
|
||||
self.parser.add_argument("--total-pm-limit", help="Total pm limit", default="10")
|
||||
self.parser.add_argument("--total-watches-limit", help="Total watches limit", default="50")
|
||||
self.parser.add_argument("--total-successful-interactions-limit", help="Total successful interactions limit", default="100")
|
||||
self.parser.add_argument(
|
||||
"--total-successful-interactions-limit", help="Total successful interactions limit", default="100"
|
||||
)
|
||||
self.parser.add_argument("--total-interactions-limit", help="Total interactions limit", default="1000")
|
||||
self.parser.add_argument("--total-scraped-limit", help="Total scraped limit", default="200")
|
||||
self.parser.add_argument("--total-crashes-limit", help="Total crashes limit", default="5")
|
||||
self.parser.add_argument("--speed-multiplier", help="Speed multiplier", default="1.0")
|
||||
|
||||
# AI Model Configuration (centralized — no hardcoded model names anywhere)
|
||||
self.parser.add_argument("--ai-model", "--ai-text-model", help="Primary LLM model (OpenRouter or Ollama)", default="qwen3.5:latest")
|
||||
self.parser.add_argument("--ai-model-url", "--ai-text-url", help="Primary LLM endpoint URL", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-telepathic-model", help="Text-based model for Telepathic Engine Fallbacks", default="qwen3.5:latest")
|
||||
self.parser.add_argument("--ai-telepathic-url", help="Telepathic model endpoint URL", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-fallback-model", "--ai-text-fallback-model", help="Fallback model when primary fails", default="qwen3.5:latest")
|
||||
self.parser.add_argument("--ai-fallback-url", "--ai-text-fallback-url", help="Fallback model endpoint URL", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-embedding-model", help="Embedding model for vector operations", default="nomic-embed-text")
|
||||
self.parser.add_argument("--ai-embedding-url", help="Embedding endpoint URL", default="http://localhost:11434/api/embeddings")
|
||||
|
||||
self.parser.add_argument(
|
||||
"--ai-model", "--ai-text-model", help="Primary LLM model (OpenRouter or Ollama)", default="qwen3.5:latest"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-model-url",
|
||||
"--ai-text-url",
|
||||
help="Primary LLM endpoint URL",
|
||||
default="http://localhost:11434/api/generate",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-telepathic-model", help="Text-based model for Telepathic Engine Fallbacks", default="qwen3.5:latest"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-telepathic-url", help="Telepathic model endpoint URL", default="http://localhost:11434/api/generate"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-fallback-model",
|
||||
"--ai-text-fallback-model",
|
||||
help="Fallback model when primary fails",
|
||||
default="qwen3.5:latest",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-fallback-url",
|
||||
"--ai-text-fallback-url",
|
||||
help="Fallback model endpoint URL",
|
||||
default="http://localhost:11434/api/generate",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-embedding-model", help="Embedding model for vector operations", default="nomic-embed-text"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-embedding-url", help="Embedding endpoint URL", default="http://localhost:11434/api/embeddings"
|
||||
)
|
||||
|
||||
# Persona & Resonance (drives ALL content evaluation and interaction decisions)
|
||||
self.parser.add_argument("--persona-interests", help="Comma-separated niche interests for content matching", default="")
|
||||
self.parser.add_argument("--ai-target-audience", help="Target audience used interchangeably with persona interests", default="")
|
||||
self.parser.add_argument("--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(
|
||||
"--persona-interests", help="Comma-separated niche interests for content matching", default=""
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-target-audience", help="Target audience used interchangeably with persona interests", default=""
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--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(
|
||||
"--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")
|
||||
self.parser.add_argument("--profile-learning-percentage", help="Percentage of profiles to deeply scan before engaging", default="0")
|
||||
self.parser.add_argument("--visual-vibe-check-percentage", help="Percentage of profiles to visually evaluate via screenshot before engaging", default="0")
|
||||
self.parser.add_argument("--ignore-close-friends", action="store_true", help="Completely ignore posts, stories, and profiles of Close Friends (Enge Freunde)")
|
||||
self.parser.add_argument(
|
||||
"--scrape-profiles", action="store_true", help="Extract and store profile metadata in CRM"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--profile-learning-percentage", help="Percentage of profiles to deeply scan before engaging", default="0"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--visual-vibe-check-percentage",
|
||||
help="Percentage of profiles to visually evaluate via screenshot before engaging",
|
||||
default="0",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ignore-close-friends",
|
||||
action="store_true",
|
||||
help="Completely ignore posts, stories, and profiles of Close Friends (Enge Freunde)",
|
||||
)
|
||||
|
||||
# Biomechanical Physics
|
||||
self.parser.add_argument("--handedness", help="Dominant hand: 'right' or 'left'. Affects thumb arc direction and tap bias.", default="right")
|
||||
self.parser.add_argument(
|
||||
"--handedness",
|
||||
help="Dominant hand: 'right' or 'left'. Affects thumb arc direction and tap bias.",
|
||||
default="right",
|
||||
)
|
||||
|
||||
|
||||
# Phase 10: RAG Comment Learning & Extractor Settings
|
||||
self.parser.add_argument("--ai-condenser-model", help="LLM used for condensing text/comments", default="qwen3.5:latest")
|
||||
self.parser.add_argument("--ai-condenser-url", help="URL for the condenser model", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-learn-comments", action="store_true", help="Extract and learn from comment sections")
|
||||
self.parser.add_argument(
|
||||
"--ai-condenser-model", help="LLM used for condensing text/comments", default="qwen3.5:latest"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-condenser-url", help="URL for the condenser model", default="http://localhost:11434/api/generate"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-learn-comments", action="store_true", help="Extract and learn from comment sections"
|
||||
)
|
||||
self.parser.add_argument("--ai-learn-niche-posts", action="store_true", help="Learn from niche posts")
|
||||
self.parser.add_argument("--ai-learn-own-profile", action="store_true", help="Learn from your own profile interactions")
|
||||
self.parser.add_argument("--ai-learn-only", action="store_true", help="Run the bot in a pure read-only learning mode")
|
||||
self.parser.add_argument("--ai-vibe", help="The specific vibe to extract from comments (e.g., friendly, controversial)", default="")
|
||||
self.parser.add_argument("--ai-blacklist-topics", help="Comma-separated topics heavily penalized or skipped", default="")
|
||||
self.parser.add_argument("--ai-quality-filter", action="store_true", help="Use AI to strictly filter the quality of posts and comments")
|
||||
self.parser.add_argument("--smart-unfollow", action="store_true", help="Enable agentic decision making for clearing the following list")
|
||||
self.parser.add_argument("--ai-vision-navigation", action="store_true", help="Capture and send base64 UI screenshots to the LLM for structural element finding")
|
||||
self.parser.add_argument("--ai-vision-context", action="store_true", help="Capture and send base64 post/DM screenshots to the LLM for contextual semantic generation")
|
||||
self.parser.add_argument(
|
||||
"--ai-learn-own-profile", action="store_true", help="Learn from your own profile interactions"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-learn-only", action="store_true", help="Run the bot in a pure read-only learning mode"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-vibe", help="The specific vibe to extract from comments (e.g., friendly, controversial)", default=""
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-blacklist-topics", help="Comma-separated topics heavily penalized or skipped", default=""
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-quality-filter",
|
||||
action="store_true",
|
||||
help="Use AI to strictly filter the quality of posts and comments",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--smart-unfollow",
|
||||
action="store_true",
|
||||
help="Enable agentic decision making for clearing the following list",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-vision-navigation",
|
||||
action="store_true",
|
||||
help="Capture and send base64 UI screenshots to the LLM for structural element finding",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-vision-context",
|
||||
action="store_true",
|
||||
help="Capture and send base64 post/DM screenshots to the LLM for contextual semantic generation",
|
||||
)
|
||||
|
||||
# on first run, we must wait to proceed with loading
|
||||
if not self.first_run:
|
||||
@@ -227,32 +313,33 @@ class Config:
|
||||
exit(0)
|
||||
if self.config:
|
||||
cleaned_config = {}
|
||||
|
||||
def flatten_dict(d, parent_key='', sep='_'):
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="_"):
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
# For users specifying account-specific overrides, preserve the dictionary structure
|
||||
# But for generic nested config like 'mission' or 'identity', flatten the keys
|
||||
if isinstance(v, dict) and any(not isinstance(sub_v, dict) for sub_v in v.values()):
|
||||
# Check if this is an account override dict (keys are usernames)
|
||||
# We assume if all values are dicts or strings, but we just flatten normally.
|
||||
# Wait, Gramaddict uses dicts for account overrides!
|
||||
# If a key is 'username' or the value has a list, it's not an override.
|
||||
pass
|
||||
|
||||
if isinstance(v, dict) and k not in ['username', 'passwords']:
|
||||
items.extend(flatten_dict(v, '', sep=sep).items())
|
||||
# Special handling for 'plugins' key: we want 'like: count' to become 'like_count'
|
||||
if k == "plugins" and not parent_key:
|
||||
if isinstance(v, dict):
|
||||
for pk, pv in v.items():
|
||||
items.extend(flatten_dict(pv, pk, sep=sep).items())
|
||||
continue
|
||||
|
||||
if isinstance(v, dict) and k not in ["username", "passwords"]:
|
||||
# If we are inside a plugin, continue prefixing
|
||||
next_prefix = f"{parent_key}{sep}{k}" if parent_key else ""
|
||||
items.extend(flatten_dict(v, next_prefix, sep=sep).items())
|
||||
else:
|
||||
items.append((k, v))
|
||||
full_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
items.append((full_key, v))
|
||||
return dict(items)
|
||||
|
||||
flat_config = flatten_dict(self.config)
|
||||
|
||||
|
||||
for k, v in flat_config.items():
|
||||
val = v
|
||||
if isinstance(v, dict):
|
||||
val = "SPECIALIZED"
|
||||
|
||||
|
||||
cleaned_config[k.replace("-", "_")] = val
|
||||
self.parser.set_defaults(**cleaned_config)
|
||||
|
||||
@@ -265,12 +352,14 @@ class Config:
|
||||
self.args, self.unknown_args = self.parser.parse_known_args(args=arg_str)
|
||||
else:
|
||||
self.args, self.unknown_args = self.parser.parse_known_args()
|
||||
|
||||
|
||||
self.device_id = self.args.device
|
||||
|
||||
|
||||
# Map actions
|
||||
if getattr(self.args, "feed", None): self.enabled.append("feed")
|
||||
if getattr(self.args, "explore", None): self.enabled.append("explore")
|
||||
if getattr(self.args, "feed", None):
|
||||
self.enabled.append("feed")
|
||||
if getattr(self.args, "explore", None):
|
||||
self.enabled.append("explore")
|
||||
|
||||
def specialize(self, username):
|
||||
if self.config is None:
|
||||
@@ -291,6 +380,32 @@ class Config:
|
||||
# Handle the case where username itself is a list - we specialize it to the current target
|
||||
self.args.username = [username] if isinstance(self.args.username, list) else username
|
||||
|
||||
def get_plugin_config(self, plugin_name: str) -> dict:
|
||||
"""
|
||||
Retrieves configuration for a specific plugin.
|
||||
First checks the 'plugins' dict. If not found, falls back to flat config values
|
||||
using the plugin_name as a prefix for backward compatibility.
|
||||
"""
|
||||
if self.config and "plugins" in self.config:
|
||||
plugin_dict = self.config["plugins"].get(plugin_name, {})
|
||||
if plugin_dict:
|
||||
return plugin_dict
|
||||
|
||||
# Backward compatibility / flat config fallback
|
||||
# e.g., for "follow" plugin, check if "follow_percentage" exists
|
||||
fallback = {}
|
||||
if hasattr(self.args, f"{plugin_name}_percentage"):
|
||||
fallback["percentage"] = getattr(self.args, f"{plugin_name}_percentage")
|
||||
|
||||
# specific hardcoded fallbacks
|
||||
if plugin_name == "close_friends_guard" and hasattr(self.args, "ignore_close_friends"):
|
||||
fallback["enabled"] = getattr(self.args, "ignore_close_friends")
|
||||
|
||||
if plugin_name == "comment_interaction" and hasattr(self.args, "dry_run_comments"):
|
||||
fallback["dry_run"] = getattr(self.args, "dry_run_comments")
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
def get_time_last_save(file_path) -> str:
|
||||
try:
|
||||
|
||||
@@ -1,121 +1,137 @@
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import uuid
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody, BezierGesture
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
from GramAddict.core.physics.biomechanics import BezierGesture, PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DarwinEngine(QdrantBase):
|
||||
"""
|
||||
Project Singularity: Continuous Bayesian Evolutionary Engine V3 (Proof of Resonance).
|
||||
Determines mathematically how to act on a per-post basis, generating custom
|
||||
Dwell Times and nonlinear scroll sequences to maximize the RL Reward Matrix.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, config_path: str = "config.yml"):
|
||||
self.username = username
|
||||
self.config_path = config_path
|
||||
super().__init__(collection_name="bot_darwin_mdp_resonance", vector_size=5) # 5 corresponds to behavior_bounds length
|
||||
|
||||
super().__init__(
|
||||
collection_name="bot_darwin_mdp_resonance", vector_size=5
|
||||
) # 5 corresponds to behavior_bounds length
|
||||
|
||||
# We replace naive percentages with Markovian Dwell Behaviors
|
||||
self.behavior_bounds = {
|
||||
"initial_dwell_sec": (1.0, 15.0, 2.0),
|
||||
"scroll_velocity": (0.1, 2.0, 0.3), # 1.0 is normal
|
||||
"scroll_velocity": (0.1, 2.0, 0.3), # 1.0 is normal
|
||||
"back_swipe_prob": (0.0, 0.4, 0.1),
|
||||
"profile_visit_prob": (0.0, 0.8, 0.2),
|
||||
"comment_read_dwell": (0.0, 20.0, 4.0)
|
||||
"comment_read_dwell": (0.0, 20.0, 4.0),
|
||||
}
|
||||
self.current_behavior = {}
|
||||
|
||||
def synthesize_interaction_profile(self, target_resonance: float, text_length: int = 0) -> dict:
|
||||
"""
|
||||
Given an AI aesthetic resonance score (0.0 to 1.0) and caption length,
|
||||
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
|
||||
|
||||
epsilon = 0.15 # 15% pure exploration
|
||||
|
||||
if not history or random.random() < epsilon:
|
||||
logger.info("🧬 [Darwin Engine] EXPLORE: Generating chaotic non-linear behavioral vector.")
|
||||
center = {k: (v[0]+v[1])/2 for k, v in self.behavior_bounds.items()}
|
||||
center = {k: (v[0] + v[1]) / 2 for k, v in self.behavior_bounds.items()}
|
||||
self.current_behavior = self._mutate(center)
|
||||
else:
|
||||
# Exploitation: Nearest neighbor matching the resonance profile closely
|
||||
best_node = max(history, key=lambda x: x[1]) # x[1] is the Reward
|
||||
best_node = max(history, key=lambda x: x[1]) # x[1] is the Reward
|
||||
best_params = best_node[0]
|
||||
logger.info(f"🧬 [Darwin Engine] EXPLOIT: Adapting proven behavioral vector from highest Peak Reward ({best_node[1]:.2f}).")
|
||||
logger.info(
|
||||
f"🧬 [Darwin Engine] EXPLOIT: Adapting proven behavioral vector from highest Peak Reward ({best_node[1]:.2f})."
|
||||
)
|
||||
self.current_behavior = self._mutate(best_params)
|
||||
|
||||
|
||||
# Modulate behavior directly by resonance
|
||||
# E.g., if resonance is 0.9 (amazing post), read comments longer!
|
||||
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)")
|
||||
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, text_length: int = 0, nav_graph=None, zero_engine=None, configs=None, resonance_oracle=None, username=None, context_xml: str = ""):
|
||||
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,
|
||||
context_xml: str = "",
|
||||
):
|
||||
"""
|
||||
Translates the mathematical interaction profile directly into device actions
|
||||
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, text_length=text_length)
|
||||
|
||||
|
||||
logger.info("🧬 [Darwin MDP] Executing Proof of Resonance Sequence...")
|
||||
|
||||
|
||||
# Pre-compute screen dimensions for all sub-phases
|
||||
info = device.get_info()
|
||||
h = info.get("displayHeight", 2400)
|
||||
w = info.get("displayWidth", 1080)
|
||||
|
||||
|
||||
# 1. Initial Dwell
|
||||
dwell = profile["initial_dwell_sec"]
|
||||
logger.debug(f" -> Dwelling for {dwell:.1f}s")
|
||||
time.sleep(dwell)
|
||||
|
||||
|
||||
# 2. Non-linear cognitive latency (Micro-Jitters)
|
||||
if profile["scroll_velocity"] != 1.0:
|
||||
logger.debug(f" -> Simulating cognitive read latency (Micro-Jitters, Velocity: {profile['scroll_velocity']:.2f})")
|
||||
logger.debug(
|
||||
f" -> Simulating cognitive read latency (Micro-Jitters, Velocity: {profile['scroll_velocity']:.2f})"
|
||||
)
|
||||
body = PhysicsBody.get_session_instance(device)
|
||||
injector = SendEventInjector.get_instance(device)
|
||||
|
||||
# Thumb starts on the right side of the screen to avoid clicking polls/tags in the center
|
||||
cx = int(w * 0.8) + device.cm_to_pixels(random.uniform(-0.3, 0.3))
|
||||
cy = h // 2
|
||||
|
||||
|
||||
# Keep distance microscopic (0.1 to 0.3 cm) so we DO NOT lose visual alignment
|
||||
distance = device.cm_to_pixels(random.uniform(0.1, 0.3))
|
||||
duration = max(0.5, 1.0 / max(0.1, profile["scroll_velocity"]))
|
||||
start_y = int(cy + distance / 2)
|
||||
end_y = int(cy - distance / 2)
|
||||
|
||||
|
||||
# Use Bézier curve for the jitter
|
||||
points = BezierGesture.scroll_curve(
|
||||
(cx, start_y), (cx, end_y), body, n_points=6
|
||||
)
|
||||
points = BezierGesture.scroll_curve((cx, start_y), (cx, end_y), body, n_points=6)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), duration * 1000)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
|
||||
# 3. Micro Back-swipe (The Human Wobble)
|
||||
if random.random() < profile["back_swipe_prob"]:
|
||||
logger.debug(" -> Executing cognitive wobble (Trace swipe)")
|
||||
@@ -124,78 +140,91 @@ class DarwinEngine(QdrantBase):
|
||||
noise_x = device.cm_to_pixels(random.uniform(-0.2, 0.2))
|
||||
cx = w // 2 + device.cm_to_pixels(random.uniform(-0.5, 0.5))
|
||||
cy = h // 2
|
||||
|
||||
|
||||
dur_ms = int(random.uniform(200, 500))
|
||||
device.shell(f"input swipe {int(cx)} {int(cy)} {int(cx + noise_x)} {int(cy + slip_distance)} {dur_ms}")
|
||||
time.sleep(random.uniform(0.5, 1.2))
|
||||
|
||||
|
||||
# 4. Comment depth simulation (probabilistic & resonance-correlated)
|
||||
if profile["comment_read_dwell"] > 1.0 and resonance > 0.4 and random.random() < 0.3:
|
||||
if nav_graph and zero_engine:
|
||||
if not self._has_comments(context_xml):
|
||||
logger.debug(" -> 🚫 [Darwin Engine] Skipping comment depth simulation (Post has 0 comments).")
|
||||
else:
|
||||
logger.debug(f" -> Opening comments section for {profile['comment_read_dwell']:.1f}s depth simulation")
|
||||
|
||||
# Capture image context of post BEFORE opening comment sheet
|
||||
b64_img_payload = None
|
||||
if configs and getattr(configs.args, "ai_vision_context", False):
|
||||
try:
|
||||
import base64
|
||||
raw = device.screenshot()
|
||||
if raw:
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
raw.save(buf, format='JPEG')
|
||||
b64_img_payload = [base64.b64encode(buf.getvalue()).decode('utf-8')]
|
||||
logger.debug("👁️ [Vision Context] Captured post screenshot for True Vision semantic analysis.")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Vision Context] Failed to capture screenshot: {e}")
|
||||
|
||||
success = nav_graph.do("tap comment button")
|
||||
if success:
|
||||
# ---- Phase 10: RAG Comment Extraction ----
|
||||
if configs and resonance_oracle and getattr(configs.args, "ai_learn_comments", False):
|
||||
# Limit scraping to 15% to avoid mechanical persistence
|
||||
if random.random() < 0.05:
|
||||
logger.debug(" -> Dumping UI hierarchy for Comment Extraction...")
|
||||
try:
|
||||
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()
|
||||
remaining_sleep = profile["comment_read_dwell"] - (t1 - t0)
|
||||
if remaining_sleep > 0:
|
||||
time.sleep(remaining_sleep)
|
||||
except Exception as e:
|
||||
logger.error(f" -> Comment extraction failed: {e}")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(" -> Skipping RAG Extraction (Probabilistic Evasion)")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
# ------------------------------------------
|
||||
|
||||
logger.debug(" -> Closing comments section")
|
||||
device.press("back")
|
||||
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.dump_hierarchy()
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
if not telepath.find_best_node(ui_dump, "post like button heart", min_confidence=0.4, device=device):
|
||||
logger.debug(" -> Not back on Home feed, pressing back again to close comment sheet/keyboard")
|
||||
device.press("back")
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
logger.debug(f" -> Could not find comment button, falling back to dwell simulation for {profile['comment_read_dwell']:.1f}s")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(f" -> Simulating comment section processing for {profile['comment_read_dwell']:.1f}s")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
|
||||
if nav_graph and zero_engine:
|
||||
if not self._has_comments(context_xml):
|
||||
logger.debug(" -> 🚫 [Darwin Engine] Skipping comment depth simulation (Post has 0 comments).")
|
||||
else:
|
||||
logger.debug(
|
||||
f" -> Opening comments section for {profile['comment_read_dwell']:.1f}s depth simulation"
|
||||
)
|
||||
|
||||
# Capture image context of post BEFORE opening comment sheet
|
||||
b64_img_payload = None
|
||||
if configs and getattr(configs.args, "ai_vision_context", False):
|
||||
try:
|
||||
import base64
|
||||
|
||||
raw = device.screenshot()
|
||||
if raw:
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
raw.save(buf, format="JPEG")
|
||||
b64_img_payload = [base64.b64encode(buf.getvalue()).decode("utf-8")]
|
||||
logger.debug(
|
||||
"👁️ [Vision Context] Captured post screenshot for True Vision semantic analysis."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Vision Context] Failed to capture screenshot: {e}")
|
||||
|
||||
success = nav_graph.do("tap comment button")
|
||||
if success:
|
||||
# ---- Phase 10: RAG Comment Extraction ----
|
||||
if configs and resonance_oracle and getattr(configs.args, "ai_learn_comments", False):
|
||||
# Limit scraping to 15% to avoid mechanical persistence
|
||||
if random.random() < 0.05:
|
||||
logger.debug(" -> Dumping UI hierarchy for Comment Extraction...")
|
||||
try:
|
||||
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()
|
||||
remaining_sleep = profile["comment_read_dwell"] - (t1 - t0)
|
||||
if remaining_sleep > 0:
|
||||
time.sleep(remaining_sleep)
|
||||
except Exception as e:
|
||||
logger.error(f" -> Comment extraction failed: {e}")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(" -> Skipping RAG Extraction (Probabilistic Evasion)")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
# ------------------------------------------
|
||||
|
||||
logger.debug(" -> Closing comments section")
|
||||
device.press("back")
|
||||
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.dump_hierarchy()
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
if not telepath.find_best_node(
|
||||
ui_dump, "post like button heart", min_confidence=0.4, device=device
|
||||
):
|
||||
logger.debug(" -> Not back on Home feed, pressing back again to close comment sheet/keyboard")
|
||||
device.press("back")
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
logger.debug(
|
||||
f" -> Could not find comment button, falling back to dwell simulation for {profile['comment_read_dwell']:.1f}s"
|
||||
)
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(f" -> Simulating comment section processing for {profile['comment_read_dwell']:.1f}s")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
|
||||
logger.info("🧬 [Darwin MDP] Interaction sequence completed safely.")
|
||||
return profile
|
||||
|
||||
@@ -211,21 +240,21 @@ class DarwinEngine(QdrantBase):
|
||||
injector = SendEventInjector.get_instance(device)
|
||||
|
||||
info = device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
# Start position from body (session-aware)
|
||||
cx, cy = body.get_scroll_start()
|
||||
# Override Y to center for wobble
|
||||
cy = h // 2
|
||||
|
||||
|
||||
# Fatigue scales wobble amplitude (tired = more sloppy)
|
||||
amplitude = 1.0 + body.fatigue * 0.5
|
||||
|
||||
|
||||
# Keep the shift small but above Android's touch slop threshold (~8dp)
|
||||
y_shift = device.cm_to_pixels(random.uniform(0.3, 0.6) * amplitude) * random.choice([1, -1])
|
||||
x_shift = device.cm_to_pixels(random.uniform(-0.2, 0.2) * amplitude)
|
||||
|
||||
|
||||
# Handedness bias: right-handers wobble right-down, left-handers left-down
|
||||
if body.handedness == "right":
|
||||
x_shift += device.cm_to_pixels(random.uniform(0, 0.1))
|
||||
@@ -235,22 +264,15 @@ class DarwinEngine(QdrantBase):
|
||||
end_x = int(cx + x_shift)
|
||||
end_y = int(cy + y_shift)
|
||||
|
||||
points = BezierGesture.scroll_curve(
|
||||
(cx, cy), (end_x, end_y), body, n_points=5
|
||||
)
|
||||
points = BezierGesture.scroll_curve((cx, cy), (end_x, end_y), body, n_points=5)
|
||||
duration_ms = random.uniform(150, 300)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), duration_ms)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
def _get_historical_landscape(self):
|
||||
try:
|
||||
records = self.client.scroll(
|
||||
collection_name=self.collection_name,
|
||||
limit=1000,
|
||||
with_payload=True
|
||||
)[0]
|
||||
records = self.client.scroll(collection_name=self.collection_name, limit=1000, with_payload=True)[0]
|
||||
return [(r.payload.get("params", {}), r.payload.get("reward", 0.0)) for r in records]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -265,25 +287,28 @@ class DarwinEngine(QdrantBase):
|
||||
|
||||
def select_arm_and_apply(self, args):
|
||||
"""
|
||||
Multi-Armed Bandit (MAB) logic to select the most promising behavioral
|
||||
Multi-Armed Bandit (MAB) logic to select the most promising behavioral
|
||||
mutation strategy for the current account phase.
|
||||
"""
|
||||
logger.info(f"🧬 [Darwin Engine] Applying MDP State channel for @{self.username}...")
|
||||
self.synthesize_interaction_profile(target_resonance=0.5) # Initial neutral bias
|
||||
self.synthesize_interaction_profile(target_resonance=0.5) # Initial neutral bias
|
||||
|
||||
def evaluate_session_end(self, duration_minutes: float, followers_gained: int):
|
||||
if duration_minutes <= 0: duration_minutes = 1.0
|
||||
if duration_minutes <= 0:
|
||||
duration_minutes = 1.0
|
||||
reward = (followers_gained / duration_minutes) * 10.0
|
||||
logger.info(f"🧬 [Darwin Engine] Session Evaluation: {followers_gained} followers gained in {duration_minutes:.1f}m. Reward: {reward:.2f}")
|
||||
logger.info(
|
||||
f"🧬 [Darwin Engine] Session Evaluation: {followers_gained} followers gained in {duration_minutes:.1f}m. Reward: {reward:.2f}"
|
||||
)
|
||||
self.emit_reward_signal(followers_gained=followers_gained, block_warnings_seen=0)
|
||||
|
||||
def emit_reward_signal(self, followers_gained: int, block_warnings_seen: int):
|
||||
if not self.current_behavior:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
reward = followers_gained - (block_warnings_seen * 50)
|
||||
|
||||
|
||||
vector = []
|
||||
for k, (p_min, p_max, _) in self.behavior_bounds.items():
|
||||
val = self.current_behavior.get(k, p_min)
|
||||
@@ -298,24 +323,24 @@ class DarwinEngine(QdrantBase):
|
||||
"username": self.username,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"params": self.current_behavior,
|
||||
"reward": reward
|
||||
"reward": reward,
|
||||
},
|
||||
log_success=f"🧬 [Darwin Engine V3] MDP Reward Matrix stored. Reward Value: {reward:.2f}"
|
||||
log_success=f"🧬 [Darwin Engine V3] MDP Reward Matrix stored. Reward Value: {reward:.2f}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"🧬 [Darwin Engine] Failed to record reward: {e}")
|
||||
|
||||
def _has_comments(self, xml_string: str) -> bool:
|
||||
"""
|
||||
Heuristic to check if a post actually has comments to read.
|
||||
Heuristic to check if a post actually has comments to read.
|
||||
If it has 0 comments, checking them is suspicious bot behavior.
|
||||
"""
|
||||
low_xml = xml_string.lower()
|
||||
|
||||
|
||||
# 1. Explicit zero comments checks
|
||||
if re.search(r'\b0\s*kommentare?\b', low_xml) or re.search(r'\b0\s*comment(?:s)?\b', low_xml):
|
||||
if re.search(r"\b0\s*kommentare?\b", low_xml) or re.search(r"\b0\s*comment(?:s)?\b", low_xml):
|
||||
return False
|
||||
|
||||
|
||||
# 2. Check for "view all" or similar prominent comment link texts
|
||||
if "view all" in low_xml or ("alle " in low_xml and "kommentare ansehen" in low_xml):
|
||||
return True
|
||||
@@ -323,15 +348,13 @@ class DarwinEngine(QdrantBase):
|
||||
return True
|
||||
if "comment number is" in low_xml:
|
||||
return True
|
||||
|
||||
|
||||
# 3. Check for specific counter elements > 0 in content descriptors
|
||||
# e.g. "by username, 23 comments" or "1,234 comments"
|
||||
has_number_of_comments = re.search(r'\b([1-9][0-9.,]*)\s*(?:comment(?:s)?|kommentare?)\b', low_xml)
|
||||
has_number_of_comments = re.search(r"\b([1-9][0-9.,]*)\s*(?:comment(?:s)?|kommentare?)\b", low_xml)
|
||||
if has_number_of_comments:
|
||||
return True
|
||||
|
||||
|
||||
# If no indicators are found, assume the post has 0 comments.
|
||||
# The comment button exists, but there are no comments to read.
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def create_device(device_id, app_id, args=None):
|
||||
try:
|
||||
return DeviceFacade(device_id, app_id, args)
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
str(e)
|
||||
err_type = str(type(e))
|
||||
if (
|
||||
"ConnectError" in err_type
|
||||
|
||||
@@ -9,9 +9,10 @@ and a structured reason tag for easy triage.
|
||||
|
||||
Retention: Keeps the last 50 dumps per reason category to avoid disk bloat.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,7 +24,7 @@ MAX_DUMPS_PER_CATEGORY = 50
|
||||
def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
"""
|
||||
Capture and save the current UI hierarchy to disk for debugging.
|
||||
|
||||
|
||||
Args:
|
||||
device: The uiautomator2 device facade.
|
||||
reason: Short tag for the failure type. Used for filename grouping.
|
||||
@@ -33,20 +34,20 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
"""
|
||||
try:
|
||||
os.makedirs(DUMP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# Capture 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")
|
||||
safe_reason = reason.replace(" ", "_").replace("/", "_")[:40]
|
||||
filename = f"{safe_reason}__{ts}.xml"
|
||||
filepath = os.path.join(DUMP_DIR, filename)
|
||||
|
||||
|
||||
# Write XML
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
|
||||
|
||||
# Write companion metadata JSON
|
||||
meta = {
|
||||
"reason": reason,
|
||||
@@ -56,7 +57,9 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
# Capture the session log if available
|
||||
try:
|
||||
import shutil
|
||||
|
||||
from GramAddict.core.log import get_log_file_config
|
||||
|
||||
log_name, log_dir, _, _ = get_log_file_config()
|
||||
if log_name and log_dir:
|
||||
active_log = os.path.join(log_dir, log_name)
|
||||
@@ -69,18 +72,18 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
|
||||
if extra_context:
|
||||
meta["context"] = extra_context
|
||||
|
||||
|
||||
meta_path = filepath.replace(".xml", ".meta.json")
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
logger.info(f"📸 [Diagnostic] UI state and session log dumped for '{reason}': {filepath}")
|
||||
|
||||
|
||||
# Rotate old dumps for this category
|
||||
_rotate_dumps(safe_reason)
|
||||
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Dumping must NEVER crash the bot
|
||||
logger.debug(f"[Diagnostic] Could not dump UI state: {e}")
|
||||
@@ -90,13 +93,10 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
def _rotate_dumps(category_prefix: str):
|
||||
"""Keep only the last MAX_DUMPS_PER_CATEGORY dumps per category."""
|
||||
try:
|
||||
all_files = sorted([
|
||||
f for f in os.listdir(DUMP_DIR)
|
||||
if f.startswith(category_prefix) and f.endswith(".xml")
|
||||
])
|
||||
|
||||
all_files = sorted([f for f in os.listdir(DUMP_DIR) if f.startswith(category_prefix) and f.endswith(".xml")])
|
||||
|
||||
if len(all_files) > MAX_DUMPS_PER_CATEGORY:
|
||||
files_to_remove = all_files[:len(all_files) - MAX_DUMPS_PER_CATEGORY]
|
||||
files_to_remove = all_files[: len(all_files) - MAX_DUMPS_PER_CATEGORY]
|
||||
for f in files_to_remove:
|
||||
xml_path = os.path.join(DUMP_DIR, f)
|
||||
meta_path = xml_path.replace(".xml", ".meta.json")
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
|
||||
"""
|
||||
Executes the autonomous Direct Messaging logic in the Zero-Latency architecture.
|
||||
Assumes the bot is already at the "MessageInbox" UI state.
|
||||
"""
|
||||
logger.info(f"🧠 [DM Engine] Initiating inbox processing in {current_target}...", extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"})
|
||||
|
||||
logger.info(
|
||||
f"🧠 [DM Engine] Initiating inbox processing in {current_target}...",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"},
|
||||
)
|
||||
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
crm = cognitive_stack.get("crm")
|
||||
|
||||
from GramAddict.core.bot_flow import sleep, dump_ui_state, _humanized_click
|
||||
|
||||
from GramAddict.core.bot_flow import _humanized_click, sleep
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.stealth_typing import ghost_type
|
||||
|
||||
|
||||
# Initialize session limits if missing
|
||||
if not hasattr(session_state, 'totalMessages'):
|
||||
if not hasattr(session_state, "totalMessages"):
|
||||
session_state.totalMessages = 0
|
||||
|
||||
|
||||
failed_attempts = 0
|
||||
|
||||
|
||||
while not dopamine.is_app_session_over():
|
||||
# Limits check
|
||||
limit_val = session_state.check_limit(SessionState.Limit.PM)
|
||||
@@ -34,79 +40,97 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
elif limit_val is True:
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
unread_threads = telepathic._extract_semantic_nodes(
|
||||
xml_dump, "find unread message threads or unread badges", threshold=0.7
|
||||
)
|
||||
|
||||
if unread_threads and not unread_threads[0].get("skip"):
|
||||
target_node = unread_threads[0]
|
||||
logger.info(f"📨 Found unread message thread. Opening.")
|
||||
logger.info("📨 Found unread message thread. Opening.")
|
||||
_humanized_click(device, target_node["x"], target_node["y"])
|
||||
sleep(2.0)
|
||||
|
||||
|
||||
# Step 2: Read the conversation context
|
||||
thread_xml = device.dump_hierarchy()
|
||||
msg_nodes = telepathic._extract_semantic_nodes(thread_xml, "find the last received message text", threshold=0.6)
|
||||
|
||||
msg_nodes = telepathic._extract_semantic_nodes(
|
||||
thread_xml, "find the last received message text", threshold=0.6
|
||||
)
|
||||
|
||||
context_text = "No previous context"
|
||||
if msg_nodes and not msg_nodes[0].get("skip") and msg_nodes[0].get("text"):
|
||||
context_text = msg_nodes[0].get("text")
|
||||
|
||||
|
||||
logger.debug(f"Last received message context: {context_text}")
|
||||
|
||||
|
||||
# Verify we aren't at limits before sending
|
||||
if not getattr(configs.args, "disable_ai_messaging", False):
|
||||
# Configure models
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
|
||||
|
||||
# Generate response
|
||||
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
|
||||
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, format_json=False, timeout=120, max_tokens=100, temperature=0.7)
|
||||
|
||||
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
format_json=False,
|
||||
timeout=120,
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
if response_dict and "response" in response_dict:
|
||||
response_text = response_dict["response"].strip()
|
||||
# Find the input field
|
||||
input_nodes = telepathic._extract_semantic_nodes(thread_xml, "find the message input text field", threshold=0.7)
|
||||
input_nodes = telepathic._extract_semantic_nodes(
|
||||
thread_xml, "find the message input text field", threshold=0.7
|
||||
)
|
||||
if input_nodes and not input_nodes[0].get("skip"):
|
||||
in_node = input_nodes[0]
|
||||
_humanized_click(device, in_node["x"], in_node["y"])
|
||||
sleep(1.0)
|
||||
|
||||
|
||||
# Type the message
|
||||
ghost_type(device, response_text, speed="fast")
|
||||
sleep(1.0)
|
||||
|
||||
|
||||
# Find Send button
|
||||
send_xml = device.dump_hierarchy()
|
||||
send_nodes = telepathic._extract_semantic_nodes(send_xml, "find the send message button", threshold=0.8)
|
||||
|
||||
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"):
|
||||
s_node = send_nodes[0]
|
||||
_humanized_click(device, s_node["x"], s_node["y"])
|
||||
logger.info("✅ [DM Engine] Successfully sent a generated reply.", extra={"color": Fore.GREEN})
|
||||
|
||||
logger.info(
|
||||
"✅ [DM Engine] Successfully sent a generated reply.", extra={"color": Fore.GREEN}
|
||||
)
|
||||
|
||||
session_state.totalMessages += 1
|
||||
if crm:
|
||||
crm.log_sent_dm("unknown_target", response_text, "", [])
|
||||
|
||||
|
||||
# Return back to inbox
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
|
||||
dopamine.boredom += random.uniform(5.0, 15.0)
|
||||
failed_attempts = 0
|
||||
else:
|
||||
logger.info("📭 No unread threads found. Inbox clear.")
|
||||
dopamine.boredom += 50.0 # Inbox clear = massive boredom = change feed
|
||||
|
||||
|
||||
if dopamine.wants_to_change_feed() or dopamine.boredom >= 100:
|
||||
logger.info("🧠 [DM Engine] Interaction complete. Transitioning back from inbox.")
|
||||
device.press("back") # Go back from inbox
|
||||
device.press("back") # Go back from inbox
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
except Exception as e:
|
||||
@@ -114,9 +138,9 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
device.press("back")
|
||||
failed_attempts += 1
|
||||
if failed_attempts > 2:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
if dopamine.is_app_session_over():
|
||||
return "SESSION_OVER"
|
||||
|
||||
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
# Import existing VLM engine and Qdrant DB for operations
|
||||
@@ -12,17 +11,19 @@ from GramAddict.core.qdrant_memory import HeuristicMemoryDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DojoEngine:
|
||||
"""
|
||||
Project Dojo: The Data Engine.
|
||||
Handles asynchronous learning from failures (Prediction Errors).
|
||||
Instead of blocking the bot when an element is not found, the bot
|
||||
offloads the snapshot to this queue. The DojoEngine recompiles the
|
||||
offloads the snapshot to this queue. The DojoEngine recompiles the
|
||||
heuristic using a heavy VLM model in the background and updates the DB.
|
||||
"Never make a mistake twice."
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, device=None):
|
||||
if cls._instance is None:
|
||||
@@ -43,7 +44,9 @@ class DojoEngine:
|
||||
self.is_running = True
|
||||
self.worker_thread = threading.Thread(target=self._process_queue, daemon=True)
|
||||
self.worker_thread.start()
|
||||
logger.info("⛩️ [Dojo Data Engine] Background learning pipeline initialized.", extra={"color": f"{Fore.CYAN}"})
|
||||
logger.info(
|
||||
"⛩️ [Dojo Data Engine] Background learning pipeline initialized.", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
self.is_running = False
|
||||
@@ -58,10 +61,13 @@ class DojoEngine:
|
||||
"name": heuristic_name,
|
||||
"xml": context_xml,
|
||||
"intent": intent_prompt,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self.learning_queue.put(snapshot)
|
||||
logger.info(f"⛩️ [Dojo] Snapshot for '{heuristic_name}' enqueued for shadow-compilation.", extra={"color": f"{Fore.CYAN}"})
|
||||
logger.info(
|
||||
f"⛩️ [Dojo] Snapshot for '{heuristic_name}' enqueued for shadow-compilation.",
|
||||
extra={"color": f"{Fore.CYAN}"},
|
||||
)
|
||||
|
||||
def _process_queue(self):
|
||||
"""
|
||||
@@ -71,24 +77,29 @@ class DojoEngine:
|
||||
try:
|
||||
# Wait for a job
|
||||
snapshot = self.learning_queue.get(timeout=5.0)
|
||||
h_name = snapshot['name']
|
||||
xml = snapshot['xml']
|
||||
intent = snapshot['intent']
|
||||
|
||||
h_name = snapshot["name"]
|
||||
xml = snapshot["xml"]
|
||||
intent = snapshot["intent"]
|
||||
|
||||
logger.info(f"⛩️ [Dojo] Processing auto-labeling job: {h_name}...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
|
||||
# Heavy compilation
|
||||
new_rule = self.compiler.generate_heuristic(intent, xml)
|
||||
|
||||
|
||||
if new_rule:
|
||||
# Overwrite legacy rule in Database (Fleet update)
|
||||
self.db.cache_heuristic(h_name, new_rule)
|
||||
logger.info(f"⛩️ [Dojo] SUCCESS! Fleet Memory updated with robust heuristic for '{h_name}'.", extra={"color": f"{Fore.GREEN}"})
|
||||
logger.info(
|
||||
f"⛩️ [Dojo] SUCCESS! Fleet Memory updated with robust heuristic for '{h_name}'.",
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⛩️ [Dojo] FAILED to compile robust heuristic for '{h_name}'.", extra={"color": f"{Fore.RED}"})
|
||||
|
||||
logger.warning(
|
||||
f"⛩️ [Dojo] FAILED to compile robust heuristic for '{h_name}'.", extra={"color": f"{Fore.RED}"}
|
||||
)
|
||||
|
||||
self.learning_queue.task_done()
|
||||
|
||||
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DopamineEngine:
|
||||
"""
|
||||
Simulation of human neurochemistry.
|
||||
Manages boredom levels and interest-based interaction pacing.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.boredom = 0.0 # 0.0 to 100.0
|
||||
self.boredom = 0.0 # 0.0 to 100.0
|
||||
self.spike_threshold = 7.0
|
||||
self.homeostasis_rate = 0.05 # decay per minute
|
||||
self.homeostasis_rate = 0.05 # decay per minute
|
||||
self.last_spike = time.time()
|
||||
self.session_start = time.time()
|
||||
self.session_limit_seconds = random.uniform(10 * 60, 35 * 60) # 10-35 mins session
|
||||
|
||||
self.session_limit_seconds = random.uniform(10 * 60, 35 * 60) # 10-35 mins session
|
||||
|
||||
def process_content(self, classification: dict):
|
||||
"""
|
||||
classification: {'quality': 'high'|'low', 'type': 'meme'|'aesthetic'|'ad', 'score': 0-10}
|
||||
"""
|
||||
score = classification.get("score", 5.0)
|
||||
quality = classification.get("quality", "medium")
|
||||
|
||||
|
||||
# Calculate spike
|
||||
spike = score * 1.5 if quality == "high" else score * 0.5
|
||||
|
||||
|
||||
# Update boredom: negative correlation with high quality content
|
||||
if spike > self.spike_threshold:
|
||||
self.boredom = max(0.0, self.boredom - (spike * 0.2))
|
||||
logger.info(f"💉 [Dopamine] Spike detected! Interest high. Boredom decreased to {self.boredom:.1f}%", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
f"💉 [Dopamine] Spike detected! Interest high. Boredom decreased to {self.boredom:.1f}%",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
else:
|
||||
self.boredom = min(100.0, self.boredom + 5.0)
|
||||
logger.info(f"💉 [Dopamine] Low interest content. Boredom increased to {self.boredom:.1f}%", extra={"color": f"{Fore.YELLOW}"})
|
||||
|
||||
logger.info(
|
||||
f"💉 [Dopamine] Low interest content. Boredom increased to {self.boredom:.1f}%",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
|
||||
self.last_spike = time.time()
|
||||
return self.is_bored()
|
||||
|
||||
@@ -52,13 +61,13 @@ class DopamineEngine:
|
||||
self.boredom = max(70.0, self.boredom - 1.5)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def wants_to_change_feed(self):
|
||||
# Engage context shift if highly bored
|
||||
if 80.0 < self.boredom < 100.0:
|
||||
return random.random() < 0.4
|
||||
return False
|
||||
|
||||
|
||||
def reset_boredom(self, decay=0.2):
|
||||
"""
|
||||
Resets boredom after a successful context shift.
|
||||
@@ -66,7 +75,10 @@ class DopamineEngine:
|
||||
"""
|
||||
old = self.boredom
|
||||
self.boredom = max(0.0, self.boredom * decay)
|
||||
logger.info(f"💉 [Dopamine] Context shifted. Boredom cooled: {old:.1f}% -> {self.boredom:.1f}%", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
f"💉 [Dopamine] Context shifted. Boredom cooled: {old:.1f}% -> {self.boredom:.1f}%",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
|
||||
def reset_session(self):
|
||||
"""
|
||||
@@ -76,7 +88,9 @@ class DopamineEngine:
|
||||
self.session_start = time.time()
|
||||
self.last_spike = time.time()
|
||||
self.session_limit_seconds = random.uniform(10 * 60, 35 * 60)
|
||||
logger.info("💉 [Dopamine] Session limits and neurochemistry reset to baseline.", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
"💉 [Dopamine] Session limits and neurochemistry reset to baseline.", extra={"color": f"{Fore.YELLOW}"}
|
||||
)
|
||||
|
||||
def is_app_session_over(self):
|
||||
# True if we have scrolled too long or hit absolute burnout
|
||||
@@ -88,9 +102,9 @@ class DopamineEngine:
|
||||
High dopamine (high interest) = longer viewing time.
|
||||
"""
|
||||
if base_score > 8:
|
||||
return random.uniform(2.0, 4.0) # Entranced
|
||||
return random.uniform(2.0, 4.0) # Entranced
|
||||
if base_score < 3:
|
||||
return random.uniform(0.1, 0.4) # Fast-swipe
|
||||
return random.uniform(0.1, 0.4) # Fast-swipe
|
||||
return 1.0
|
||||
|
||||
def decay(self):
|
||||
|
||||
@@ -16,8 +16,8 @@ All parameters persist in Qdrant, surviving restarts.
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,13 +27,13 @@ logger = logging.getLogger(__name__)
|
||||
# Think of them as the "physics" constraints of the system.
|
||||
SAFETY_BOUNDS = {
|
||||
"scroll_correction_probability": (0.05, 0.35), # Never below 5%, never above 35%
|
||||
"boredom_decay_rate": (0.05, 0.5), # How fast boredom accumulates
|
||||
"resonance_threshold": (0.3, 0.9), # Content quality filter
|
||||
"interaction_cooldown_seconds": (1.0, 10.0), # Min pause between interactions
|
||||
"max_follows_per_session": (5, 40), # Absolute follow cap
|
||||
"max_likes_per_session": (10, 80), # Absolute like cap
|
||||
"session_duration_target_minutes": (15, 120), # Session length target
|
||||
"story_view_probability": (0.1, 0.8), # How often to view stories
|
||||
"boredom_decay_rate": (0.05, 0.5), # How fast boredom accumulates
|
||||
"resonance_threshold": (0.3, 0.9), # Content quality filter
|
||||
"interaction_cooldown_seconds": (1.0, 10.0), # Min pause between interactions
|
||||
"max_follows_per_session": (5, 40), # Absolute follow cap
|
||||
"max_likes_per_session": (10, 80), # Absolute like cap
|
||||
"session_duration_target_minutes": (15, 120), # Session length target
|
||||
"story_view_probability": (0.1, 0.8), # How often to view stories
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class Genome:
|
||||
The bot's behavioral DNA — a set of evolvable parameters.
|
||||
Each parameter has a current value and respects hard safety bounds.
|
||||
"""
|
||||
|
||||
scroll_correction_probability: float = 0.15
|
||||
boredom_decay_rate: float = 0.2
|
||||
resonance_threshold: float = 0.7
|
||||
@@ -51,15 +52,15 @@ class Genome:
|
||||
max_likes_per_session: int = 30
|
||||
session_duration_target_minutes: float = 45.0
|
||||
story_view_probability: float = 0.4
|
||||
|
||||
|
||||
# Metadata
|
||||
generation: int = 0
|
||||
best_fitness: float = 0.0
|
||||
last_updated: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Genome":
|
||||
# Filter out unknown keys for forward-compatibility
|
||||
@@ -74,6 +75,7 @@ class SessionResult:
|
||||
Outcome metrics from a completed session.
|
||||
Used to calculate fitness for the current genome.
|
||||
"""
|
||||
|
||||
follows_gained: int = 0
|
||||
likes_given: int = 0
|
||||
stories_viewed: int = 0
|
||||
@@ -86,7 +88,7 @@ class SessionResult:
|
||||
class EvolutionEngine:
|
||||
"""
|
||||
Genetic algorithm for behavioral parameter optimization.
|
||||
|
||||
|
||||
Lifecycle:
|
||||
1. Load genome from Qdrant (or use defaults)
|
||||
2. Bot uses genome parameters during session
|
||||
@@ -95,49 +97,50 @@ class EvolutionEngine:
|
||||
5. If fitness decreased → mutate genome (try new params)
|
||||
6. Persist genome to Qdrant
|
||||
"""
|
||||
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, username: str = None) -> "EvolutionEngine":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls(username or "default")
|
||||
return cls._instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
cls._instance = None
|
||||
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.username = username
|
||||
self.genome = Genome()
|
||||
self._qdrant_connected = False
|
||||
self._load_genome()
|
||||
|
||||
|
||||
def _load_genome(self):
|
||||
"""Load persisted genome from Qdrant, or use defaults."""
|
||||
try:
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
self._db = QdrantBase("evolution_genomes_v1", vector_size=128)
|
||||
|
||||
|
||||
if not self._db.is_connected:
|
||||
logger.debug("[Evolution] Qdrant not available. Using default genome.")
|
||||
return
|
||||
|
||||
|
||||
self._qdrant_connected = True
|
||||
|
||||
|
||||
# Try to recall existing genome
|
||||
vec = self._db._get_embedding(f"genome_{self.username}")
|
||||
if not vec:
|
||||
return
|
||||
|
||||
|
||||
results = self._db.client.query_points(
|
||||
collection_name=self._db.collection_name,
|
||||
query=vec,
|
||||
limit=1,
|
||||
score_threshold=0.95,
|
||||
).points
|
||||
|
||||
|
||||
if results:
|
||||
payload = results[0].payload
|
||||
genome_data = payload.get("genome", {})
|
||||
@@ -149,93 +152,93 @@ class EvolutionEngine:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Failed to load genome: {e}")
|
||||
|
||||
|
||||
def _save_genome(self):
|
||||
"""Persist genome to Qdrant."""
|
||||
if not self._qdrant_connected:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
vec = self._db._get_embedding(f"genome_{self.username}")
|
||||
if not vec:
|
||||
return
|
||||
|
||||
|
||||
self.genome.last_updated = time.time()
|
||||
payload = {
|
||||
"username": self.username,
|
||||
"genome": self.genome.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
self._db.upsert_point(
|
||||
f"genome_{self.username}",
|
||||
payload,
|
||||
vector=vec,
|
||||
log_success=f"🧬 [Evolution] Saved genome generation {self.genome.generation}"
|
||||
log_success=f"🧬 [Evolution] Saved genome generation {self.genome.generation}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Failed to save genome: {e}")
|
||||
|
||||
|
||||
def compute_fitness(self, result: SessionResult) -> float:
|
||||
"""
|
||||
Computes a fitness score [0.0 - 1.0] from session outcomes.
|
||||
|
||||
|
||||
Reward:
|
||||
- Follows gained (high value)
|
||||
- Likes given (medium value)
|
||||
- Stories viewed (low value)
|
||||
- Longer sessions (moderate value)
|
||||
|
||||
|
||||
Penalty:
|
||||
- Blocks received (SEVERE penalty — 50% fitness reduction per block)
|
||||
- High prediction error rate (moderate penalty)
|
||||
"""
|
||||
if result.blocks_received > 0:
|
||||
# Blocks are catastrophic — any genome that triggers a block is unfit
|
||||
block_penalty = 0.5 ** result.blocks_received
|
||||
block_penalty = 0.5**result.blocks_received
|
||||
logger.warning(
|
||||
f"🧬 [Evolution] BLOCK PENALTY: {result.blocks_received} blocks → "
|
||||
f"fitness multiplier {block_penalty:.3f}"
|
||||
)
|
||||
else:
|
||||
block_penalty = 1.0
|
||||
|
||||
|
||||
# Normalize outcomes to [0, 1] range
|
||||
follow_score = min(result.follows_gained / 20.0, 1.0) # Cap at 20
|
||||
like_score = min(result.likes_given / 50.0, 1.0) # Cap at 50
|
||||
story_score = min(result.stories_viewed / 20.0, 1.0) # Cap at 20
|
||||
like_score = min(result.likes_given / 50.0, 1.0) # Cap at 50
|
||||
story_score = min(result.stories_viewed / 20.0, 1.0) # Cap at 20
|
||||
duration_score = min(result.duration_minutes / 60.0, 1.0) # Cap at 60 min
|
||||
|
||||
|
||||
# Prediction accuracy bonus
|
||||
accuracy_bonus = 1.0 - result.prediction_error_rate
|
||||
|
||||
|
||||
# Weighted fitness
|
||||
raw_fitness = (
|
||||
follow_score * 0.35 + # Follows are most valuable
|
||||
like_score * 0.20 + # Likes are secondary
|
||||
story_score * 0.05 + # Stories are minor
|
||||
duration_score * 0.15 + # Session stability matters
|
||||
accuracy_bonus * 0.25 # Prediction accuracy = environmental mastery
|
||||
follow_score * 0.35 # Follows are most valuable
|
||||
+ like_score * 0.20 # Likes are secondary
|
||||
+ story_score * 0.05 # Stories are minor
|
||||
+ duration_score * 0.15 # Session stability matters
|
||||
+ accuracy_bonus * 0.25 # Prediction accuracy = environmental mastery
|
||||
)
|
||||
|
||||
|
||||
fitness = raw_fitness * block_penalty
|
||||
fitness = max(0.0, min(1.0, fitness)) # Clamp to [0, 1]
|
||||
|
||||
|
||||
return round(fitness, 4)
|
||||
|
||||
|
||||
def evolve(self, result: SessionResult):
|
||||
"""
|
||||
Evaluate session and evolve the genome.
|
||||
|
||||
|
||||
If fitness improved → lock parameters (exploitation)
|
||||
If fitness decreased → mutate parameters (exploration)
|
||||
"""
|
||||
fitness = self.compute_fitness(result)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"🧬 [Evolution] Generation {self.genome.generation} fitness: {fitness:.4f} "
|
||||
f"(best: {self.genome.best_fitness:.4f})"
|
||||
)
|
||||
|
||||
|
||||
if fitness >= self.genome.best_fitness:
|
||||
# ── Exploitation: Lock winning parameters ──
|
||||
logger.info(f"🧬 [Evolution] ✅ Fitness improved! Locking generation {self.genome.generation}.")
|
||||
@@ -244,41 +247,41 @@ class EvolutionEngine:
|
||||
# ── Exploration: Mutate parameters ──
|
||||
logger.info(f"🧬 [Evolution] 🔀 Fitness regressed. Mutating for generation {self.genome.generation + 1}.")
|
||||
self._mutate()
|
||||
|
||||
|
||||
self.genome.generation += 1
|
||||
self._save_genome()
|
||||
|
||||
|
||||
def _mutate(self, mutation_rate: float = 0.15):
|
||||
"""
|
||||
Mutate genome parameters within safety bounds.
|
||||
|
||||
|
||||
Each parameter has a `mutation_rate` chance of being modified.
|
||||
Mutations are small (±10-20% of current value) to ensure gradual evolution.
|
||||
"""
|
||||
for param_name, (low, high) in SAFETY_BOUNDS.items():
|
||||
if random.random() > mutation_rate:
|
||||
continue
|
||||
|
||||
|
||||
current = getattr(self.genome, param_name, None)
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
|
||||
# Mutation: ±10-20% of range
|
||||
param_range = high - low
|
||||
delta = random.uniform(-0.2, 0.2) * param_range
|
||||
|
||||
|
||||
new_value = current + delta
|
||||
|
||||
|
||||
# Clamp to safety bounds
|
||||
if isinstance(current, int):
|
||||
new_value = int(max(low, min(high, round(new_value))))
|
||||
else:
|
||||
new_value = max(low, min(high, new_value))
|
||||
|
||||
|
||||
old_value = current
|
||||
setattr(self.genome, param_name, new_value)
|
||||
logger.debug(f"🧬 [Mutation] {param_name}: {old_value} → {new_value}")
|
||||
|
||||
|
||||
def get_param(self, name: str, default: Any = None) -> Any:
|
||||
"""Get a parameter value from the current genome."""
|
||||
return getattr(self.genome, name, default)
|
||||
|
||||
@@ -1,66 +1,71 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from GramAddict.core.qdrant_memory import PersonaMemoryDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GrowthBrain:
|
||||
"""
|
||||
Biological Feedback and Persona Management.
|
||||
|
||||
|
||||
Two critical functions:
|
||||
1. Circadian Rhythm — modulates ALL sleep/dwell times based on time of day
|
||||
2. Persona Refinement — learns from interaction outcomes and stores insights
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, persona_interests: list[str] = None):
|
||||
self.username = username
|
||||
self.persona_memory = PersonaMemoryDB()
|
||||
self.persona_interests = persona_interests or []
|
||||
self.strategy = "aggressive_growth" # Will be updated by orchestrator
|
||||
self.strategy = "aggressive_growth" # Will be updated by orchestrator
|
||||
self.last_learning_at = datetime.now()
|
||||
|
||||
|
||||
def evaluate_governance(self, dopamine_engine, job_target: str, is_reels: bool = False) -> str:
|
||||
"""
|
||||
Global Strategy Oracle.
|
||||
Decides if the bot should stay in the current feed, check curiosity targets,
|
||||
or escape due to boredom.
|
||||
|
||||
|
||||
Returns: "STAY", "SHIFT_CONTEXT", "CHECK_CURIOSITY"
|
||||
"""
|
||||
# 1. Boredom Check (Priority 1)
|
||||
if dopamine_engine.boredom > 85.0 and random.random() < 0.2:
|
||||
logger.info("🧠 [GrowthBrain] Supreme boredom reached or periodic shift triggered. Decision: SHIFT_CONTEXT.")
|
||||
logger.info(
|
||||
"🧠 [GrowthBrain] Supreme boredom reached or periodic shift triggered. Decision: SHIFT_CONTEXT."
|
||||
)
|
||||
return "SHIFT_CONTEXT"
|
||||
|
||||
|
||||
# 2. Curiosity Check (Priority 2)
|
||||
# Only in main feeds, not during deep reels sessions
|
||||
if job_target.lower() in ("homefeed", "feed", "home") and not is_reels:
|
||||
if random.random() < 0.06:
|
||||
logger.info("🧠 [GrowthBrain] Spontaneous curiosity spike. Decision: CHECK_CURIOSITY.")
|
||||
return "CHECK_CURIOSITY"
|
||||
|
||||
|
||||
return "STAY"
|
||||
|
||||
def get_current_desire(self, dopamine_engine, available_targets=None) -> str:
|
||||
"""
|
||||
Agent Core: Determines what the bot actually WANTS to do right now,
|
||||
based on strategy, circadian rhythm, and dopamine/boredom levels.
|
||||
|
||||
|
||||
Returns a high-level semantic Desire string.
|
||||
"""
|
||||
if dopamine_engine.boredom > 80.0:
|
||||
logger.info("🧠 [GrowthBrain] Internal drive: Context shift required.")
|
||||
return "ShiftContext"
|
||||
|
||||
|
||||
weights = {}
|
||||
if self.strategy == "aggressive_growth":
|
||||
weights = {
|
||||
"DiscoverNewContent": 60, # Explore, Reels
|
||||
"NurtureCommunity": 15, # HomeFeed
|
||||
"SocialReciprocity": 25, # Follow list, DMs
|
||||
"NurtureCommunity": 15, # HomeFeed
|
||||
"SocialReciprocity": 25, # Follow list, DMs
|
||||
}
|
||||
elif self.strategy == "community_builder":
|
||||
weights = {
|
||||
@@ -74,31 +79,31 @@ class GrowthBrain:
|
||||
"NurtureCommunity": 20,
|
||||
"SocialReciprocity": 0,
|
||||
}
|
||||
else: # stealth_lurker
|
||||
else: # stealth_lurker
|
||||
weights = {
|
||||
"DiscoverNewContent": 40,
|
||||
"NurtureCommunity": 50,
|
||||
"SocialReciprocity": 10,
|
||||
}
|
||||
|
||||
|
||||
choices = []
|
||||
for desire, weight in weights.items():
|
||||
choices.extend([desire] * weight)
|
||||
|
||||
|
||||
selected_desire = random.choice(choices)
|
||||
logger.info(f"🧠 [GrowthBrain] Strategy '{self.strategy}' dictated Desire: {selected_desire}")
|
||||
return selected_desire
|
||||
|
||||
|
||||
def get_circadian_pacing(self) -> float:
|
||||
"""
|
||||
Adjusts activity levels based on the current local time
|
||||
Adjusts activity levels based on the current local time
|
||||
to simulate human sleep/wake cycles.
|
||||
|
||||
|
||||
Returns a multiplier (0.1 to 1.0) that should be applied to ALL sleep durations.
|
||||
Lower = slower (more human-like during off-hours).
|
||||
"""
|
||||
hour = datetime.now().hour
|
||||
|
||||
|
||||
# Determine current pacing state
|
||||
if 2 <= hour <= 5:
|
||||
pacing = 0.1
|
||||
@@ -120,39 +125,39 @@ class GrowthBrain:
|
||||
pacing = 1.0
|
||||
state_id = "peak_hours"
|
||||
msg = "🧠 [GrowthBrain] Peak metabolic rate. Performance 100%."
|
||||
|
||||
|
||||
# Log intelligently (only info log on state change)
|
||||
if not hasattr(self, '_last_pacing_state') or getattr(self, '_last_pacing_state') != state_id:
|
||||
if not hasattr(self, "_last_pacing_state") or getattr(self, "_last_pacing_state") != state_id:
|
||||
logger.info(msg, extra={"color": f"{Fore.GREEN}"})
|
||||
self._last_pacing_state = state_id
|
||||
else:
|
||||
logger.debug(msg)
|
||||
|
||||
|
||||
return pacing
|
||||
|
||||
def refine_persona(self, interaction_outcomes: list[dict]):
|
||||
"""
|
||||
Learns from interaction outcomes to refine persona understanding.
|
||||
|
||||
|
||||
interaction_outcomes: [{'username': str, 'action': 'like'|'comment'|'skip', 'resonance': float}]
|
||||
|
||||
|
||||
Stores high-performing interaction patterns in PersonaMemoryDB.
|
||||
"""
|
||||
if not interaction_outcomes:
|
||||
return
|
||||
|
||||
|
||||
# Find interactions that had high resonance (those are our niche)
|
||||
high_res = [o for o in interaction_outcomes if o.get("resonance", 0) > 0.7]
|
||||
low_res = [o for o in interaction_outcomes if o.get("resonance", 0) < 0.3]
|
||||
|
||||
|
||||
if high_res:
|
||||
insight = f"High-resonance interactions in this session: {len(high_res)} posts matched niche."
|
||||
self.persona_memory.store_persona_insight("session_learning", insight)
|
||||
logger.info(
|
||||
f"🧠 [GrowthBrain] Session learning: {len(high_res)} high-resonance, {len(low_res)} low-resonance posts.",
|
||||
extra={"color": f"{Fore.GREEN}"}
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
|
||||
self.last_learning_at = datetime.now()
|
||||
|
||||
def get_persona_context(self) -> str:
|
||||
@@ -160,7 +165,7 @@ class GrowthBrain:
|
||||
base = ""
|
||||
if self.persona_interests:
|
||||
base = f"Core interests: {', '.join(self.persona_interests)}"
|
||||
|
||||
|
||||
learned = self.persona_memory.get_persona_context()
|
||||
if learned:
|
||||
return f"{base}\n{learned}" if base else learned
|
||||
@@ -171,7 +176,8 @@ class GrowthBrain:
|
||||
def wants_to_double_tap(self, is_reel: bool = False) -> bool:
|
||||
"""Determines if the bot should use double-tap for likes."""
|
||||
prob = 0.45 if self.strategy == "aggressive_growth" else 0.25
|
||||
if is_reel: prob += 0.20 # People double-tap reels more often
|
||||
if is_reel:
|
||||
prob += 0.20 # People double-tap reels more often
|
||||
return random.random() < prob
|
||||
|
||||
def evaluate_hesitation(self) -> bool:
|
||||
@@ -182,6 +188,7 @@ class GrowthBrain:
|
||||
|
||||
def wants_to_repost(self, resonance_score: float) -> bool:
|
||||
"""Decides if content is worthy of a repost."""
|
||||
if resonance_score < 0.85: return False
|
||||
if resonance_score < 0.85:
|
||||
return False
|
||||
prob = 0.3 if self.strategy == "aggressive_growth" else 0.1
|
||||
return random.random() < prob
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Perception — Feed and Content Analysis."""
|
||||
|
||||
from GramAddict.core.perception.feed_analysis import (
|
||||
FEED_MARKERS,
|
||||
CAROUSEL_INDICATORS,
|
||||
has_carousel_in_view,
|
||||
FEED_MARKERS,
|
||||
extract_post_content,
|
||||
has_carousel_in_view,
|
||||
has_feed_markers,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PersistentList(list):
|
||||
def __init__(self, filename, encoder=None):
|
||||
super().__init__()
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Physics — Humanized Input Simulation, Biomechanics & UI Timing."""
|
||||
|
||||
from GramAddict.core.physics.biomechanics import (
|
||||
BezierGesture,
|
||||
PhysicsBody,
|
||||
)
|
||||
from GramAddict.core.physics.humanized_input import (
|
||||
humanized_scroll,
|
||||
humanized_click,
|
||||
humanized_horizontal_swipe,
|
||||
)
|
||||
from GramAddict.core.physics.timing import (
|
||||
wait_for_post_loaded,
|
||||
wait_for_story_loaded,
|
||||
align_active_post,
|
||||
)
|
||||
from GramAddict.core.physics.biomechanics import (
|
||||
PhysicsBody,
|
||||
BezierGesture,
|
||||
humanized_scroll,
|
||||
)
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
from GramAddict.core.physics.timing import (
|
||||
align_active_post,
|
||||
wait_for_post_loaded,
|
||||
wait_for_story_loaded,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"humanized_scroll",
|
||||
@@ -26,5 +26,4 @@ __all__ = [
|
||||
"PhysicsBody",
|
||||
"BezierGesture",
|
||||
"SendEventInjector",
|
||||
|
||||
]
|
||||
|
||||
@@ -253,27 +253,15 @@ class BezierGesture:
|
||||
t = i / n_points
|
||||
|
||||
# Cubic Bézier interpolation
|
||||
x = (
|
||||
(1 - t) ** 3 * sx
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_x
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_x
|
||||
+ t ** 3 * ex
|
||||
)
|
||||
y = (
|
||||
(1 - t) ** 3 * sy
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_y
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_y
|
||||
+ t ** 3 * ey
|
||||
)
|
||||
x = (1 - t) ** 3 * sx + 3 * (1 - t) ** 2 * t * cp1_x + 3 * (1 - t) * t**2 * cp2_x + t**3 * ex
|
||||
y = (1 - t) ** 3 * sy + 3 * (1 - t) ** 2 * t * cp1_y + 3 * (1 - t) * t**2 * cp2_y + t**3 * ey
|
||||
|
||||
# Micro-noise on each point (finger vibration)
|
||||
x += random.gauss(0, 1.5)
|
||||
y += random.gauss(0, 1.5)
|
||||
|
||||
# Pressure curve: Gaussian peak around t=0.4 (peak contact mid-gesture)
|
||||
pressure = pressure_baseline + 0.3 * math.exp(
|
||||
-((t - 0.4) ** 2) / 0.1
|
||||
)
|
||||
pressure = pressure_baseline + 0.3 * math.exp(-((t - 0.4) ** 2) / 0.1)
|
||||
pressure += random.uniform(-0.04, 0.04)
|
||||
pressure = max(0.08, min(0.92, pressure))
|
||||
|
||||
@@ -343,24 +331,12 @@ class BezierGesture:
|
||||
|
||||
for i in range(n_points + 1):
|
||||
t = i / n_points
|
||||
x = (
|
||||
(1 - t) ** 3 * sx
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_x
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_x
|
||||
+ t ** 3 * ex
|
||||
)
|
||||
y = (
|
||||
(1 - t) ** 3 * sy
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_y
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_y
|
||||
+ t ** 3 * ey
|
||||
)
|
||||
x = (1 - t) ** 3 * sx + 3 * (1 - t) ** 2 * t * cp1_x + 3 * (1 - t) * t**2 * cp2_x + t**3 * ex
|
||||
y = (1 - t) ** 3 * sy + 3 * (1 - t) ** 2 * t * cp1_y + 3 * (1 - t) * t**2 * cp2_y + t**3 * ey
|
||||
x += random.gauss(0, 2)
|
||||
y += random.gauss(0, 2)
|
||||
|
||||
pressure = pressure_baseline + 0.25 * math.exp(
|
||||
-((t - 0.45) ** 2) / 0.12
|
||||
)
|
||||
pressure = pressure_baseline + 0.25 * math.exp(-((t - 0.45) ** 2) / 0.12)
|
||||
pressure += random.uniform(-0.04, 0.04)
|
||||
pressure = max(0.08, min(0.92, pressure))
|
||||
|
||||
@@ -390,7 +366,7 @@ class BezierGesture:
|
||||
t = i / (n_points - 1) if n_points > 1 else 0.5
|
||||
# Inverted sigmoid: fast in middle, slow at edges
|
||||
# Higher value = longer delay = slower movement
|
||||
sigmoid = 1.0 / (1.0 + math.exp(-8 * (t - 0.5)))
|
||||
1.0 / (1.0 + math.exp(-8 * (t - 0.5)))
|
||||
# U-shaped: slow at start & end, fast in middle
|
||||
speed_factor = 0.4 + 1.2 * (4 * (t - 0.5) ** 2)
|
||||
raw_intervals.append(speed_factor)
|
||||
@@ -401,9 +377,7 @@ class BezierGesture:
|
||||
intervals = [(r / total_raw) * total_sec for r in raw_intervals]
|
||||
|
||||
# Add micro-jitter to timing (humans are never perfectly rhythmic)
|
||||
intervals = [
|
||||
max(0.002, i + random.uniform(-0.003, 0.003)) for i in intervals
|
||||
]
|
||||
intervals = [max(0.002, i + random.uniform(-0.003, 0.003)) for i in intervals]
|
||||
|
||||
return intervals
|
||||
|
||||
@@ -412,8 +386,8 @@ class BezierGesture:
|
||||
"""
|
||||
Generates a J-curve timing schedule for flick/swipe gestures.
|
||||
|
||||
Unlike the sigmoid (which slows down at the end), this curve
|
||||
accelerates through the middle and maintains high velocity
|
||||
Unlike the sigmoid (which slows down at the end), this curve
|
||||
accelerates through the middle and maintains high velocity
|
||||
until the very last point to simulate a sudden 'liftoff' flick.
|
||||
This allows Android's ScrollView to register a high fling velocity.
|
||||
|
||||
@@ -427,7 +401,7 @@ class BezierGesture:
|
||||
for i in range(n_points):
|
||||
t = i / (n_points - 1)
|
||||
# Starts slow (larger delay), speeds up continuously (smaller delay)
|
||||
speed_factor = 1.0 - (0.8 * t)
|
||||
speed_factor = 1.0 - (0.8 * t)
|
||||
raw_intervals.append(speed_factor)
|
||||
|
||||
total_raw = sum(raw_intervals)
|
||||
|
||||
@@ -15,10 +15,9 @@ import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody, BezierGesture
|
||||
from GramAddict.core.physics.biomechanics import BezierGesture, PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -51,9 +50,8 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
if is_skip:
|
||||
# Aggressive fast fling to skip quickly. NO CORRECTIONS.
|
||||
distance = int(h * random.uniform(0.6, 0.75))
|
||||
duration = random.uniform(150, 250) # slightly longer to ensure smooth fling registration
|
||||
duration = random.uniform(150, 250) # slightly longer to ensure smooth fling registration
|
||||
end_y = start_y - distance
|
||||
do_correction = False # Force false
|
||||
else:
|
||||
# Playful, organic human scrolling
|
||||
play_choice = random.random()
|
||||
@@ -111,9 +109,7 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
logger.debug("🦴 [Biomechanics] Mid-scroll reading pause")
|
||||
|
||||
# --- Generate Bézier Curve ---
|
||||
points = BezierGesture.scroll_curve(
|
||||
(start_x, start_y), (int(end_x), end_y), body
|
||||
)
|
||||
points = BezierGesture.scroll_curve((start_x, start_y), (int(end_x), end_y), body)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), duration)
|
||||
|
||||
# Pre-touch dwell: hold finger on glass before moving
|
||||
@@ -131,8 +127,6 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
points.insert(mid + 1, pause_point)
|
||||
timing.insert(mid, pause_duration)
|
||||
|
||||
|
||||
|
||||
# --- Inject Gesture ---
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
@@ -148,12 +142,10 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
corr_end_y = corr_start_y - corr_distance # Scroll back down
|
||||
|
||||
corr_points = BezierGesture.scroll_curve(
|
||||
(corr_start_x, corr_start_y), (corr_start_x, corr_end_y), body,
|
||||
n_points=6
|
||||
(corr_start_x, corr_start_y), (corr_start_x, corr_end_y), body, n_points=6
|
||||
)
|
||||
corr_timing = BezierGesture.compute_sigmoid_timing(len(corr_points), 200)
|
||||
|
||||
|
||||
injector.inject_gesture(corr_points, corr_timing, touch_major=body.get_touch_major())
|
||||
|
||||
|
||||
@@ -168,7 +160,6 @@ def humanized_click(device, x, y, double=False, sleep_mod=1.0):
|
||||
tap_duration = random.uniform(40, 90)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), tap_duration)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
if double:
|
||||
@@ -194,14 +185,9 @@ def humanized_horizontal_swipe(device, start_x, end_x, y, duration_ms):
|
||||
# Timing wobble (+/- 30%)
|
||||
actual_duration = int(duration_ms * random.uniform(0.7, 1.3))
|
||||
|
||||
points = BezierGesture.horizontal_swipe_curve(
|
||||
(actual_start_x, actual_y), (actual_end_x, actual_y), body
|
||||
)
|
||||
points = BezierGesture.horizontal_swipe_curve((actual_start_x, actual_y), (actual_end_x, actual_y), body)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), actual_duration)
|
||||
|
||||
direction = "→" if end_x > start_x else "←"
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ correct /dev/input/eventX and the axis ranges on first use.
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from time import sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,9 +40,9 @@ class SendEventInjector:
|
||||
|
||||
# Multitouch protocol B codes (most modern Android devices)
|
||||
ABS_MT_TRACKING_ID = 0x39 # 57
|
||||
ABS_MT_POSITION_X = 0x35 # 53
|
||||
ABS_MT_POSITION_Y = 0x36 # 54
|
||||
ABS_MT_PRESSURE = 0x3A # 58
|
||||
ABS_MT_POSITION_X = 0x35 # 53
|
||||
ABS_MT_POSITION_Y = 0x36 # 54
|
||||
ABS_MT_PRESSURE = 0x3A # 58
|
||||
ABS_MT_TOUCH_MAJOR = 0x30 # 48
|
||||
|
||||
SYN_REPORT = 0
|
||||
@@ -88,16 +87,14 @@ class SendEventInjector:
|
||||
line = line.strip()
|
||||
|
||||
# Device header: /dev/input/eventX
|
||||
dev_match = re.match(r'add device \d+:\s*(/dev/input/event\d+)', line)
|
||||
dev_match = re.match(r"add device \d+:\s*(/dev/input/event\d+)", line)
|
||||
if dev_match:
|
||||
current_device = dev_match.group(1)
|
||||
|
||||
# Check for multitouch capability
|
||||
if current_device and "ABS_MT_POSITION_X" in line:
|
||||
self.event_device = current_device
|
||||
logger.info(
|
||||
f"🖐️ [SendEvent] Touch device detected: {self.event_device}"
|
||||
)
|
||||
logger.info(f"🖐️ [SendEvent] Touch device detected: {self.event_device}")
|
||||
|
||||
# Parse axis ranges from the same section
|
||||
self._parse_axis_ranges(result, current_device)
|
||||
@@ -105,17 +102,11 @@ class SendEventInjector:
|
||||
return
|
||||
|
||||
# If no MT device found, try fallback pattern
|
||||
logger.debug(
|
||||
"⚠️ [SendEvent] No multitouch device found. "
|
||||
"Falling back to `input swipe` mode."
|
||||
)
|
||||
logger.debug("⚠️ [SendEvent] No multitouch device found. " "Falling back to `input swipe` mode.")
|
||||
self._fallback_mode = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"⚠️ [SendEvent] Device detection failed: {e}. "
|
||||
f"Falling back to `input swipe` mode."
|
||||
)
|
||||
logger.warning(f"⚠️ [SendEvent] Device detection failed: {e}. " f"Falling back to `input swipe` mode.")
|
||||
self._fallback_mode = True
|
||||
|
||||
def _parse_axis_ranges(self, getevent_output, device_path):
|
||||
@@ -134,19 +125,19 @@ class SendEventInjector:
|
||||
|
||||
if in_device:
|
||||
if "ABS_MT_POSITION_X" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.x_max = int(m.group(1))
|
||||
elif "ABS_MT_POSITION_Y" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.y_max = int(m.group(1))
|
||||
elif "ABS_MT_PRESSURE" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.pressure_max = int(m.group(1))
|
||||
elif "ABS_MT_TOUCH_MAJOR" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.touch_major_max = int(m.group(1))
|
||||
|
||||
@@ -262,6 +253,4 @@ class SendEventInjector:
|
||||
ex, ey, _ = points[-1]
|
||||
total_ms = int(sum(timing_intervals) * 1000) if timing_intervals else 300
|
||||
|
||||
self.device.shell(
|
||||
f"input swipe {int(sx)} {int(sy)} {int(ex)} {int(ey)} {total_ms}"
|
||||
)
|
||||
self.device.shell(f"input swipe {int(sx)} {int(sy)} {int(ex)} {int(ey)} {total_ms}")
|
||||
|
||||
@@ -13,8 +13,8 @@ import re
|
||||
import time
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.perception.feed_analysis import FEED_MARKERS
|
||||
from GramAddict.core.diagnostic_dump import dump_ui_state
|
||||
from GramAddict.core.perception.feed_analysis import FEED_MARKERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,13 +22,13 @@ logger = logging.getLogger(__name__)
|
||||
def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
"""
|
||||
Polls the UI hierarchy until feed markers appear, confirming a post is on screen.
|
||||
|
||||
|
||||
If timeout is reached, attempts Adaptive Snap recovery:
|
||||
1. Detects trap states (Story/Reel viewer, Profile)
|
||||
2. Presses BACK to escape
|
||||
3. Micro-wobbles to force render
|
||||
"""
|
||||
|
||||
|
||||
start = time.time()
|
||||
xml = ""
|
||||
while time.time() - start < timeout:
|
||||
@@ -37,7 +37,7 @@ def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
if any(marker in xml for marker in FEED_MARKERS):
|
||||
logger.debug("📱 Post loaded successfully.")
|
||||
return True
|
||||
|
||||
|
||||
# Handle high-latency loads
|
||||
if "android.widget.ProgressBar" in xml or "loading_spinner" in xml.lower():
|
||||
# Extend timeout by 5 seconds if we're about to time out and still loading
|
||||
@@ -47,10 +47,10 @@ def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
except Exception:
|
||||
pass
|
||||
sleep(0.5)
|
||||
|
||||
|
||||
logger.warning("⚠️ Post did not load within timeout. Attempting Adaptive Snap.")
|
||||
dump_ui_state(device, "post_load_timeout", {"timeout_sec": timeout})
|
||||
|
||||
|
||||
try:
|
||||
xml_lower = xml.lower()
|
||||
# 1. Trapped in a Story or Reel viewer? Press back.
|
||||
@@ -63,29 +63,29 @@ def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
if any(marker in xml for marker in FEED_MARKERS):
|
||||
logger.info("✅ Recovered to Feed.")
|
||||
return True
|
||||
|
||||
|
||||
# 2. Trapped in Profile?
|
||||
if "profile_header" in xml_lower and "row_feed_photo_profile_name" not in xml_lower:
|
||||
logger.warning("🧗 [Adaptive Snap] Trapped in Profile. Pressing BACK.")
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
|
||||
|
||||
# 3. Stuck on Grid? The tap didn't register. Do not wobble.
|
||||
grid_markers = ["explore_tab", "explore_grid", "grid_card_layout_container", "profile_tabs_container"]
|
||||
if any(m in xml_lower for m in grid_markers):
|
||||
logger.warning("🧗 [Adaptive Snap] Detected bot is STILL on the Grid. Tap likely missed. Aborting snap.")
|
||||
return False
|
||||
|
||||
|
||||
# 4. Stuck between posts (Feed markers not fully visible)? Micro-wobble.
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
logger.warning("🧗 [Adaptive Snap] Wobbling to force render.")
|
||||
device.swipe(int(w/2), int(h/2), int(w/2), int(h/2) - 100, 0.1)
|
||||
device.swipe(int(w / 2), int(h / 2), int(w / 2), int(h / 2) - 100, 0.1)
|
||||
sleep(0.5)
|
||||
device.swipe(int(w/2), int(h/2) - 100, int(w/2), int(h/2), 0.1)
|
||||
device.swipe(int(w / 2), int(h / 2) - 100, int(w / 2), int(h / 2), 0.1)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [Adaptive Snap] Failed: {e}")
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -101,16 +101,18 @@ def wait_for_story_loaded(device, timeout=5):
|
||||
except Exception:
|
||||
pass
|
||||
sleep(0.5)
|
||||
|
||||
|
||||
logger.warning("⚠️ Story did not load within timeout.")
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_profile_loaded(device, timeout=5):
|
||||
"""Polls the UI hierarchy until profile markers appear."""
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
PROFILE_MARKERS = ["profile_header", "action_bar_title", "profile_tabs_container"]
|
||||
|
||||
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
xml_lower = device.dump_hierarchy().lower()
|
||||
@@ -120,12 +122,11 @@ def wait_for_profile_loaded(device, timeout=5):
|
||||
except Exception:
|
||||
pass
|
||||
sleep(0.5)
|
||||
|
||||
|
||||
logger.warning("⚠️ Profile did not load within timeout.")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def align_active_post(device):
|
||||
"""
|
||||
Programmatic snapping correction. Finds the nearest post header and perfectly
|
||||
@@ -135,41 +136,39 @@ def align_active_post(device):
|
||||
aligned = False
|
||||
attempts = 0
|
||||
max_attempts = 3
|
||||
|
||||
|
||||
while not aligned and attempts < max_attempts:
|
||||
attempts += 1
|
||||
try:
|
||||
xml = device.dump_hierarchy()
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
target_node = telepath.find_best_node(
|
||||
xml, "post author header profile",
|
||||
min_confidence=0.4, device=device
|
||||
)
|
||||
|
||||
target_node = telepath.find_best_node(xml, "post author header profile", min_confidence=0.4, device=device)
|
||||
|
||||
if target_node:
|
||||
original_attribs = target_node.get('original_attribs', {})
|
||||
bounds = original_attribs.get('bounds', '')
|
||||
original_attribs = target_node.get("original_attribs", {})
|
||||
bounds = original_attribs.get("bounds", "")
|
||||
if not bounds:
|
||||
bounds = target_node.get('bounds', '')
|
||||
|
||||
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
|
||||
bounds = target_node.get("bounds", "")
|
||||
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
if m:
|
||||
l, t, r, b = map(int, m.groups())
|
||||
header_y = (t + b) // 2
|
||||
|
||||
|
||||
# Instagram's optimal top margin for a snapped post is ~200-280px
|
||||
target_y = 250
|
||||
diff = header_y - target_y
|
||||
|
||||
|
||||
# If target is off-center (> 100px), execute precise correction swipe
|
||||
if abs(diff) > 100:
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
cx = w // 2
|
||||
|
||||
|
||||
max_safe_swipe = int(h * 0.4)
|
||||
|
||||
|
||||
if diff > 0:
|
||||
# Content is too LOW. Move it UP.
|
||||
dist = min(diff, max_safe_swipe)
|
||||
@@ -180,7 +179,7 @@ def align_active_post(device):
|
||||
dist = min(abs(diff), max_safe_swipe)
|
||||
start_y = int(h * 0.3)
|
||||
end_y = start_y + dist
|
||||
|
||||
|
||||
# Duration 1.0s = precise mechanical drag with ZERO momentum
|
||||
device.swipe(cx, start_y, cx, end_y, duration=1.0)
|
||||
sleep(1.0)
|
||||
@@ -192,7 +191,7 @@ def align_active_post(device):
|
||||
except Exception as e:
|
||||
logger.debug(f"📐 [Alignment] Snapping correction failed: {e}")
|
||||
break
|
||||
|
||||
|
||||
if aligned and attempts > 1:
|
||||
logger.debug(f"📐 [Alignment] Snapped post cleanly into view after {attempts} attempts.")
|
||||
return True
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import logging
|
||||
import json
|
||||
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
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
import time
|
||||
|
||||
from GramAddict.core.compiler_engine import VLMCompilerEngine
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.transitions = {} # Action (e.g. "tap_search") -> Node
|
||||
self.transitions = {} # Action (e.g. "tap_search") -> Node
|
||||
|
||||
|
||||
class QNavGraph:
|
||||
"""
|
||||
Topological Navigation Map
|
||||
Maintains a directed graph of UI states. Instead of hardcoded navigation scripts,
|
||||
Maintains a directed graph of UI states. Instead of hardcoded navigation scripts,
|
||||
the bot traverses this graph. If a path fails, it invokes the VLMCompilerEngine to repair it.
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
self.nodes = {}
|
||||
@@ -32,11 +32,10 @@ class QNavGraph:
|
||||
self.nav_memory = NavigationMemoryDB()
|
||||
self.sae = SituationalAwarenessEngine.get_instance(device)
|
||||
self.goap = GoalExecutor.get_instance(device)
|
||||
|
||||
|
||||
self.compiler = VLMCompilerEngine(device)
|
||||
self._load_graph()
|
||||
|
||||
|
||||
def _load_graph(self):
|
||||
"""Loads the topological map from Qdrant. Merges with core seeds from ScreenTopology (SSOT)."""
|
||||
logger.debug("🌐 [NavGraph] Syncing topological map with Qdrant...")
|
||||
@@ -46,8 +45,11 @@ class QNavGraph:
|
||||
core_nodes = {}
|
||||
for screen_type, transitions in ScreenTopology.TRANSITIONS.items():
|
||||
# Reverse lookup: ScreenType → QNavGraph string name from SSOT
|
||||
screen_name_map = {v: k for k, v in ScreenTopology.SCREEN_NAME_MAP.items()
|
||||
if v not in (ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID) or k not in ("StoriesFeed", "SearchFeed")}
|
||||
screen_name_map = {
|
||||
v: k
|
||||
for k, v in ScreenTopology.SCREEN_NAME_MAP.items()
|
||||
if v not in (ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID) or k not in ("StoriesFeed", "SearchFeed")
|
||||
}
|
||||
node_name = screen_name_map.get(screen_type)
|
||||
if not node_name:
|
||||
continue
|
||||
@@ -72,7 +74,6 @@ class QNavGraph:
|
||||
"""Deprecated: Navigation state is now persisted per-transition in Qdrant."""
|
||||
pass
|
||||
|
||||
|
||||
def navigate_to(self, target_state: str, zero_engine, recovery_attempts: int = 0):
|
||||
"""
|
||||
GOAP-powered autonomous navigation.
|
||||
@@ -80,18 +81,19 @@ class QNavGraph:
|
||||
using hardcoded state machines and BFS pathfinding.
|
||||
"""
|
||||
logger.info(f"📍 [GOAP] Navigating autonomously to: {target_state}")
|
||||
|
||||
|
||||
# Set bot username for screen identity
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
args = getattr(Config(), 'args', None)
|
||||
if args and hasattr(args, 'username'):
|
||||
|
||||
args = getattr(Config(), "args", None)
|
||||
if args and hasattr(args, "username"):
|
||||
self.goap.screen_id.bot_username = args.username.lower()
|
||||
except Exception as e:
|
||||
logger.debug(f"⚠️ [GOAP] Skipping username sync: {e}")
|
||||
|
||||
|
||||
success = self.goap.navigate_to_screen(target_state)
|
||||
|
||||
|
||||
if success:
|
||||
self.current_state = target_state
|
||||
logger.info(f"✅ [GOAP] Reached {target_state}")
|
||||
@@ -99,24 +101,28 @@ class QNavGraph:
|
||||
logger.error(f"❌ [GOAP] Failed to reach {target_state}")
|
||||
# Final fallback: force app start and reset
|
||||
if recovery_attempts < 2:
|
||||
logger.warning(f"🔄 [GOAP Recovery] Step {recovery_attempts + 1}: Attempting app restart to escape softlock...")
|
||||
logger.warning(
|
||||
f"🔄 [GOAP Recovery] Step {recovery_attempts + 1}: Attempting app restart to escape softlock..."
|
||||
)
|
||||
self.device.app_start(self.device.app_id, use_monkey=True)
|
||||
random_sleep(3.0, 4.5)
|
||||
self.current_state = "HomeFeed"
|
||||
# Clear GOAP status for fresh attempt
|
||||
return self.navigate_to(target_state, zero_engine, recovery_attempts + 1)
|
||||
else:
|
||||
logger.critical(f"🛑 [GOAP Recovery] Max recovery attempts reached. Navigation to {target_state} aborted.")
|
||||
|
||||
logger.critical(
|
||||
f"🛑 [GOAP Recovery] Max recovery attempts reached. Navigation to {target_state} aborted."
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
def do(self, goal: str) -> bool:
|
||||
"""
|
||||
GOAP-powered action execution.
|
||||
Replaces _execute_transition() for post interactions.
|
||||
|
||||
|
||||
Screen-aware: refuses to attempt actions that don't exist on the current screen.
|
||||
|
||||
|
||||
Usage:
|
||||
nav_graph.do("like this post") # instead of _execute_transition("tap_like_button")
|
||||
nav_graph.do("follow this user") # instead of _execute_transition("tap_follow_button")
|
||||
@@ -124,14 +130,14 @@ class QNavGraph:
|
||||
"""
|
||||
# ── Screen sanity check: is this action possible here? ──
|
||||
screen = self.goap.perceive()
|
||||
available = screen.get('available_actions', [])
|
||||
screen_type = screen['screen_type']
|
||||
|
||||
available = screen.get("available_actions", [])
|
||||
screen_type = screen["screen_type"]
|
||||
|
||||
# Map goal to the action that should be available
|
||||
action_checks = {
|
||||
'like': 'tap like button',
|
||||
'comment': 'tap comment button',
|
||||
'share': 'tap share button',
|
||||
"like": "tap like button",
|
||||
"comment": "tap comment button",
|
||||
"share": "tap share button",
|
||||
}
|
||||
for keyword, required_action in action_checks.items():
|
||||
if keyword in goal.lower() and required_action not in available:
|
||||
@@ -140,7 +146,7 @@ class QNavGraph:
|
||||
f"('{required_action}' not available on this screen)"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
return self.goap._execute_action(goal)
|
||||
|
||||
def _find_path(self, start: str, end: str):
|
||||
@@ -170,19 +176,20 @@ class QNavGraph:
|
||||
Executes a transition (e.g. 'tap_explore_tab') using the Telepathic Semantic Engine.
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
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.dump_hierarchy()
|
||||
|
||||
|
||||
# ── Z-Depth Guard / Anomaly Obstacle Clearance ──
|
||||
cleared_something = self._clear_anomaly_obstacles(xml_dump=context_xml)
|
||||
if cleared_something:
|
||||
# Re-acquire context after clearing obstacle
|
||||
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"
|
||||
# We add some common synonyms for Instagram to help the vector engine
|
||||
@@ -209,21 +216,31 @@ class QNavGraph:
|
||||
"tap_newsfeed_tab": "tap activity heart icon notifications",
|
||||
}
|
||||
intent_description = intent_map.get(action, action.replace("_", " "))
|
||||
|
||||
|
||||
# Use TelepathicEngine to find the most likely node for this intent
|
||||
# If vector score < 0.82, it will trigger the Vision Cortex Fallback (VLM)
|
||||
# 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)
|
||||
|
||||
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...")
|
||||
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).")
|
||||
logger.error(
|
||||
f"❌ [Modal Recovery] Persistent blockage for '{action}'. Escalating to Context Lost (App Restart)."
|
||||
)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
if not best_node:
|
||||
@@ -231,62 +248,76 @@ class QNavGraph:
|
||||
# Check if we are even in the right app
|
||||
current_app = self.device._get_current_app()
|
||||
if current_app != self.device.app_id:
|
||||
logger.warning(f"⚠️ [Context Lost] Currently in '{current_app}', expected '{self.device.app_id}'. Transition '{action}' aborted.")
|
||||
logger.warning(
|
||||
f"⚠️ [Context Lost] Currently in '{current_app}', expected '{self.device.app_id}'. Transition '{action}' aborted."
|
||||
)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
|
||||
# Try again if within retries, UI might be animating
|
||||
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,
|
||||
|
||||
# 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...")
|
||||
logger.warning(
|
||||
"📍 [Escape] Home tab not found after all retries. Attempting final BACK press to escape sub-view..."
|
||||
)
|
||||
self.device.press("back")
|
||||
time.sleep(2.0)
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
if best_node.get("skip") or (best_node.get("selected") and "tab" in action):
|
||||
logger.info(f"⏭️ Skipping physical tap for '{action}' (Semantic Fast-Path indicated state already fulfilled)")
|
||||
logger.info(
|
||||
f"⏭️ Skipping physical tap for '{action}' (Semantic Fast-Path indicated state already fulfilled)"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
source_tag = best_node.get("source", "telepathic").replace("_", " ").title()
|
||||
logger.info(f"QNavGraph executing transition '{action}' via [{source_tag}] (Score: {best_node.get('score', 1.0):.3f})")
|
||||
|
||||
logger.info(
|
||||
f"QNavGraph executing transition '{action}' via [{source_tag}] (Score: {best_node.get('score', 1.0):.3f})"
|
||||
)
|
||||
|
||||
# Execute click
|
||||
self.device.click(obj=best_node)
|
||||
time.sleep(random.uniform(1.6, 2.8))
|
||||
|
||||
|
||||
# ── Post-Click Verification: Did it work? ──
|
||||
post_click_xml = self.device.dump_hierarchy()
|
||||
|
||||
|
||||
# ── App Perimeter Guard (SAE-powered) ──
|
||||
post_situation = self.sae.perceive(post_click_xml)
|
||||
if post_situation in (SituationType.OBSTACLE_FOREIGN_APP, SituationType.OBSTACLE_SYSTEM, SituationType.OBSTACLE_MODAL):
|
||||
logger.warning(f"🚨 [SAE Perimeter] Transition '{action}' caused drift ({post_situation.value}). Initiating autonomous recovery...")
|
||||
if post_situation in (
|
||||
SituationType.OBSTACLE_FOREIGN_APP,
|
||||
SituationType.OBSTACLE_SYSTEM,
|
||||
SituationType.OBSTACLE_MODAL,
|
||||
):
|
||||
logger.warning(
|
||||
f"🚨 [SAE Perimeter] Transition '{action}' caused drift ({post_situation.value}). Initiating autonomous recovery..."
|
||||
)
|
||||
failed_positions.add((best_node["x"], best_node["y"]))
|
||||
engine.reject_click(intent_description)
|
||||
|
||||
|
||||
# Let SAE handle recovery autonomously
|
||||
recovered = self.sae.ensure_clear_screen(max_attempts=5)
|
||||
if not recovered:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
|
||||
# Screen is clear but the transition itself failed — retry
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [SAE Recovery] Screen recovered. Retrying transition '{action}'...")
|
||||
continue
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
|
||||
# 1. Semantic Verification (Hardened)
|
||||
is_verified = engine.verify_success(intent_description, post_click_xml)
|
||||
|
||||
|
||||
# 2. UI Change Verification (Fallback/Navigation)
|
||||
ui_changed = post_click_xml != context_xml
|
||||
|
||||
|
||||
if is_verified and ui_changed:
|
||||
engine.confirm_click(intent_description)
|
||||
return True
|
||||
@@ -295,27 +326,33 @@ class QNavGraph:
|
||||
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})...")
|
||||
logger.info(
|
||||
f"🔄 [Autonomy] UI unchanged. Retrying transition '{action}' ({attempt + 1}/{max_retries})..."
|
||||
)
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
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.")
|
||||
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
|
||||
logger.info("🛡️ [Safety Reset] Pressing BACK to clear potential accidental menu/sub-view.")
|
||||
self.device.press("back")
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [Autonomy] Negative learning acquired. Retrying transition '{action}' ({attempt + 1}/{max_retries})...")
|
||||
logger.info(
|
||||
f"🔄 [Autonomy] Negative learning acquired. Retrying transition '{action}' ({attempt + 1}/{max_retries})..."
|
||||
)
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
return False
|
||||
|
||||
def _repair_transition(self, action: str):
|
||||
@@ -324,13 +361,14 @@ class QNavGraph:
|
||||
and write a new rule for `action`.
|
||||
"""
|
||||
from GramAddict.core.dojo_engine import DojoEngine
|
||||
|
||||
dojo = DojoEngine.get_instance(self.device)
|
||||
|
||||
logger.warning(f"⛩️ [Dojo] Enqueuing auto-labeling job for missing '{action}'.", extra={"color": f"\x1b[36m"})
|
||||
|
||||
logger.warning(f"⛩️ [Dojo] Enqueuing auto-labeling job for missing '{action}'.", extra={"color": "\x1b[36m"})
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
|
||||
|
||||
dojo.submit_snapshot(
|
||||
heuristic_name=action,
|
||||
context_xml=context_xml,
|
||||
intent_prompt=f"Find the button that performs: {action}. Be extremely robust against structural UI changes."
|
||||
intent_prompt=f"Find the button that performs: {action}. Be extremely robust against structural UI changes.",
|
||||
)
|
||||
|
||||
@@ -258,7 +258,7 @@ class HeuristicMemoryDB(QdrantBase):
|
||||
return
|
||||
try:
|
||||
# We hash the intent description to create a deterministic PointID
|
||||
doc_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, intent_description))
|
||||
str(uuid.uuid5(uuid.NAMESPACE_DNS, intent_description))
|
||||
vector = self._get_embedding(intent_description)
|
||||
if not vector:
|
||||
return
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from GramAddict.core.qdrant_memory import ContentMemoryDB, PersonaMemoryDB, ParasocialCRMDB, CommentMemoryDB
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.qdrant_memory import CommentMemoryDB, ContentMemoryDB, ParasocialCRMDB, PersonaMemoryDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResonanceEngine:
|
||||
"""
|
||||
The Aesthetic Oracle — Real AI Content Evaluation.
|
||||
|
||||
|
||||
Calculates semantic alignment (Resonance Score) between the bot's
|
||||
configured persona interests and target content using vector embeddings.
|
||||
|
||||
|
||||
This drives ALL downstream decisions:
|
||||
- Like probability (score >= 0.35)
|
||||
- Comment probability (score >= 0.8)
|
||||
@@ -22,6 +24,7 @@ class ResonanceEngine:
|
||||
- Dopamine spike intensity
|
||||
- Darwin dwell time modulation
|
||||
"""
|
||||
|
||||
def __init__(self, my_username: str, persona_interests: list[str] = None, crm: ParasocialCRMDB = None):
|
||||
self.my_username = my_username
|
||||
self.content_memory = ContentMemoryDB()
|
||||
@@ -29,12 +32,11 @@ class ResonanceEngine:
|
||||
self.crm = crm
|
||||
self.threshold = 0.5
|
||||
|
||||
|
||||
# The persona vector is the mathematical identity of what content we care about.
|
||||
# It's generated from config's persona_interests and cached for the entire session.
|
||||
self._persona_vector: Optional[list] = None
|
||||
self._persona_interests = persona_interests or []
|
||||
|
||||
|
||||
# Bootstrap persona on init
|
||||
if self._persona_interests:
|
||||
self._bootstrap_persona()
|
||||
@@ -46,48 +48,46 @@ class ResonanceEngine:
|
||||
"""
|
||||
persona_text = f"Content about: {', '.join(self._persona_interests)}"
|
||||
self._persona_vector = self.content_memory._get_embedding(persona_text)
|
||||
|
||||
|
||||
if self._persona_vector:
|
||||
# Store in PersonaMemoryDB for persistence across sessions
|
||||
self.persona_memory.store_persona_insight(
|
||||
"interests",
|
||||
f"Core niche interests: {', '.join(self._persona_interests)}"
|
||||
"interests", f"Core niche interests: {', '.join(self._persona_interests)}"
|
||||
)
|
||||
logger.info(
|
||||
f"✨ [Resonance Oracle] Persona vector initialized from config: {self._persona_interests}",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
else:
|
||||
logger.warning("✨ [Resonance Oracle] Could not generate persona embedding. Falling back to neutral scoring.")
|
||||
logger.warning(
|
||||
"✨ [Resonance Oracle] Could not generate persona embedding. Falling back to neutral scoring."
|
||||
)
|
||||
|
||||
def update_identity(self, persona: list, vibe: str):
|
||||
"""Dynamically update the core agent identity and embeddings during a session"""
|
||||
self._persona_interests = persona
|
||||
|
||||
|
||||
# Build embedding for updated persona
|
||||
combined_text = " ".join(self._persona_interests)
|
||||
new_vector = self.content_memory._get_embedding(combined_text)
|
||||
|
||||
|
||||
if new_vector:
|
||||
self._persona_vector = new_vector
|
||||
self.persona_memory.store_persona_insight(
|
||||
"interests",
|
||||
f"Dynamically updated interests: {', '.join(self._persona_interests)}"
|
||||
"interests", f"Dynamically updated interests: {', '.join(self._persona_interests)}"
|
||||
)
|
||||
logger.info(
|
||||
f"✨ [Resonance Oracle] Identity dynamically updated! New Persona: {self._persona_interests} | Vibe: {vibe}",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
else:
|
||||
logger.warning("✨ [Resonance Oracle] Failed to build embedding for new identity. Retaining previous state.")
|
||||
logger.warning(
|
||||
"✨ [Resonance Oracle] Failed to build embedding for new identity. Retaining previous state."
|
||||
)
|
||||
|
||||
def _classification_to_score(self, classification: str) -> float:
|
||||
"""Maps semantic classification labels to numerical scores."""
|
||||
mapping = {
|
||||
"high": 0.85,
|
||||
"medium": 0.5,
|
||||
"low": 0.2
|
||||
}
|
||||
mapping = {"high": 0.85, "medium": 0.5, "low": 0.2}
|
||||
return mapping.get(classification.lower(), 0.5)
|
||||
|
||||
def _cosine_similarity(self, v1: list, v2: list) -> float:
|
||||
@@ -107,73 +107,73 @@ class ResonanceEngine:
|
||||
"""
|
||||
username = post_content.get("username", "Unknown")
|
||||
description = post_content.get("description", "")
|
||||
|
||||
|
||||
logger.info(f"✨ [Resonance Oracle] Evaluating content from @{username}...", extra={"color": f"{Fore.MAGENTA}"})
|
||||
|
||||
|
||||
# Build a rich text representation of the post
|
||||
|
||||
description = post_content.get("description", "")
|
||||
caption = post_content.get("caption", "")
|
||||
username = post_content.get("username", "")
|
||||
|
||||
|
||||
content_text = " ".join(filter(None, [description, caption])).strip()
|
||||
|
||||
|
||||
if not content_text or len(content_text) < 5:
|
||||
logger.debug("✨ [Resonance] Post has no extractable content. Neutral score.")
|
||||
return 0.5 # Neutral — can't evaluate what we can't see
|
||||
|
||||
|
||||
# 0. Ads are now checked upstream structurally via `is_ad(xml)` in bot_flow.
|
||||
# This prevents false positives from users writing 'Werbung' in non-ad contexts.
|
||||
|
||||
|
||||
# 1. Check ContentMemoryDB cache — have we seen nearly identical content?
|
||||
cached = self.content_memory.get_cached_evaluation(content_text)
|
||||
if cached:
|
||||
score = self._classification_to_score(cached.get("classification", "medium"))
|
||||
logger.info(
|
||||
f"✨ [Resonance Cache Hit] '{content_text[:40]}...' → {score*100:.1f}%",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
return score
|
||||
|
||||
|
||||
# 2. No persona vector? Can't do real evaluation.
|
||||
if not self._persona_vector:
|
||||
logger.debug("✨ [Resonance] No persona vector. Configure persona_interests in config.yml.")
|
||||
return 0.5
|
||||
|
||||
|
||||
# 3. Generate embedding of the post content
|
||||
post_vector = self.content_memory._get_embedding(content_text)
|
||||
if not post_vector:
|
||||
return 0.5
|
||||
|
||||
|
||||
# 4. Cosine similarity against persona = resonance score
|
||||
raw_score = self._cosine_similarity(post_vector, self._persona_vector)
|
||||
|
||||
|
||||
# Normalize: text-embedding-3-small cosine similarity for text embeddings typically ranges 0.15 (completely distinct) to 0.55 (very matched, but not literal identical copies)
|
||||
# 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(
|
||||
content_text[:500], # Cap length for storage
|
||||
classification,
|
||||
f"Resonance: {score:.3f} (raw cosine: {raw_score:.3f})"
|
||||
f"Resonance: {score:.3f} (raw cosine: {raw_score:.3f})",
|
||||
)
|
||||
|
||||
|
||||
# 6. Feed the Parasocial CRM
|
||||
if self.crm and username:
|
||||
intent = f"aesthetic_evaluation_{classification}"
|
||||
# Stage mapping: high resonance -> stage 1 (Curiosity)
|
||||
new_stage = 1 if classification == "high" else None
|
||||
self.crm.log_interaction(username, intent, new_stage=new_stage)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✨ [Resonance Oracle] '{content_text[:50]}...' → {score*100:.1f}% ({classification})",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
return score
|
||||
|
||||
@@ -184,21 +184,40 @@ class ResonanceEngine:
|
||||
"""
|
||||
tragic_keywords = [
|
||||
# English
|
||||
"rip", "rest in peace", "tragedy", "died", "killed", "accident", "shooting",
|
||||
"funeral", "sad news", "memorial", "cancer", "disease", "breaking news",
|
||||
"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"
|
||||
"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.")
|
||||
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
|
||||
|
||||
return current_score
|
||||
|
||||
def judge_interaction(self, score: float) -> bool:
|
||||
"""
|
||||
@@ -220,46 +239,55 @@ class ResonanceEngine:
|
||||
def get_suggested_action(self, username: str, base_resonance: float) -> str:
|
||||
"""
|
||||
[Phase 2] High-fidelity relationship escalation.
|
||||
Determines the 'best' interaction based on content resonance AND
|
||||
Determines the 'best' interaction based on content resonance AND
|
||||
past engagement history (CRM).
|
||||
"""
|
||||
if not self.crm or not username:
|
||||
# Default logic: Like if resonance is good enough
|
||||
if base_resonance >= 0.7: return "LIKE"
|
||||
if base_resonance >= 0.7:
|
||||
return "LIKE"
|
||||
return "SKIP"
|
||||
|
||||
relationship = self.crm.get_relationship_stage(username)
|
||||
stage = relationship.get("stage", 0)
|
||||
|
||||
|
||||
# ── Escalation Logic ──
|
||||
# Stage 0: Awareness (Seen/Cold) -> Only Like
|
||||
# Stage 1: Curiosity (Interacted once) -> Like + Comment
|
||||
# Stage 2: Rapport (Multiple interactions) -> Like + Comment + Follow
|
||||
# Stage 3: Conversion (Max relationship) -> High-frequency engagement
|
||||
|
||||
|
||||
if stage == 0:
|
||||
if base_resonance >= 0.85: return "COMMENT" # Instant hook if amazing
|
||||
if base_resonance >= 0.60: return "LIKE"
|
||||
if base_resonance >= 0.85:
|
||||
return "COMMENT" # Instant hook if amazing
|
||||
if base_resonance >= 0.60:
|
||||
return "LIKE"
|
||||
elif stage == 1:
|
||||
if base_resonance >= 0.70: return "COMMENT"
|
||||
if base_resonance >= 0.40: return "LIKE"
|
||||
if base_resonance >= 0.70:
|
||||
return "COMMENT"
|
||||
if base_resonance >= 0.40:
|
||||
return "LIKE"
|
||||
elif stage >= 2:
|
||||
if base_resonance >= 0.60: return "COMMENT"
|
||||
if base_resonance >= 0.30: return "LIKE"
|
||||
|
||||
if base_resonance >= 0.60:
|
||||
return "COMMENT"
|
||||
if base_resonance >= 0.30:
|
||||
return "LIKE"
|
||||
|
||||
return "SKIP"
|
||||
|
||||
# ── [Phase 3] Engagement Decision Logic ──
|
||||
|
||||
def wants_to_reply(self, base_resonance: float) -> bool:
|
||||
"""Decides if the bot should reply to a comment."""
|
||||
if base_resonance < 0.75: return False
|
||||
if base_resonance < 0.75:
|
||||
return False
|
||||
# CRM stage 1+ increases reply chance
|
||||
return random.random() < 0.35
|
||||
|
||||
def wants_to_deep_engage(self, base_resonance: float) -> bool:
|
||||
"""Decides if the bot should click through to a commenter profile."""
|
||||
if base_resonance < 0.8: return False
|
||||
if base_resonance < 0.8:
|
||||
return False
|
||||
return random.random() < 0.25
|
||||
|
||||
def extract_and_learn_comments(self, xml_hierarchy: str, configs, author: str = "unknown", images_b64: list = None):
|
||||
@@ -271,36 +299,43 @@ class ResonanceEngine:
|
||||
"""
|
||||
if not configs or not getattr(configs.args, "ai_learn_comments", False):
|
||||
return
|
||||
|
||||
|
||||
vibe = getattr(configs.args, "ai_vibe", "")
|
||||
blacklist = getattr(configs.args, "ai_blacklist_topics", "")
|
||||
if not vibe:
|
||||
return # No vibe to learn
|
||||
|
||||
logger.info(f"🧠 [Comment Learning] Extracting comments matching vibe: '{vibe}'...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
|
||||
logger.info(
|
||||
f"🧠 [Comment Learning] Extracting comments matching vibe: '{vibe}'...", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
|
||||
# 1. Very basic semantic extraction (grab text nodes that look like comments)
|
||||
raw_comments = []
|
||||
|
||||
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
for node in root.iter('node'):
|
||||
for node in root.iter("node"):
|
||||
# 1. Block System UI (Notifications, WiFi, etc)
|
||||
pkg = node.get("package", "").lower()
|
||||
if pkg != "com.instagram.android":
|
||||
continue
|
||||
|
||||
|
||||
text = node.get("text", "")
|
||||
content_desc = node.get("content-desc", "")
|
||||
val = (text if text else content_desc).strip()
|
||||
res_id = node.get("resource-id", "").lower()
|
||||
|
||||
|
||||
# 2. Heuristics: Only target comment text views
|
||||
is_comment_node = "comment" in res_id or "textview" in res_id
|
||||
|
||||
|
||||
# 3. Block accessibility garbage & UI labels
|
||||
is_ui_junk = val.lower().startswith("go to") or val.lower().startswith("tap to") or "actions for this post" in val.lower()
|
||||
is_ui_junk = (
|
||||
val.lower().startswith("go to")
|
||||
or val.lower().startswith("tap to")
|
||||
or "actions for this post" in val.lower()
|
||||
)
|
||||
|
||||
if val and len(val) > 2 and is_comment_node and not is_ui_junk:
|
||||
if val.lower() not in ["reply", "like", "view replies", "see translation", "hide replies"]:
|
||||
@@ -308,17 +343,19 @@ class ResonanceEngine:
|
||||
except Exception as e:
|
||||
logger.error(f"🧠 [Comment Learning] Failed to parse XML: {e}")
|
||||
return
|
||||
|
||||
|
||||
if not raw_comments:
|
||||
logger.debug("🧠 [Comment Learning] No legible comments found in UI.")
|
||||
return
|
||||
|
||||
|
||||
# Deduplicate and limit
|
||||
raw_comments = list(set(raw_comments))[:10]
|
||||
logger.debug(f"🧠 [Comment Learning] Scraped {len(raw_comments)} potential comment nodes. Passing to Condenser...")
|
||||
|
||||
logger.debug(
|
||||
f"🧠 [Comment Learning] Scraped {len(raw_comments)} potential comment nodes. Passing to Condenser..."
|
||||
)
|
||||
|
||||
logger.debug(f"🧠 [Comment Learning] Raw texts passed to Condenser:\n{chr(10).join(raw_comments)}")
|
||||
|
||||
|
||||
# 2. Filter via VLM Condenser
|
||||
prompt = (
|
||||
f"Evaluate these Instagram comments. Your goal is to identify comments that generally match this vibe while blocking SPAM, UI junk, and harmful topics.\n"
|
||||
@@ -329,22 +366,32 @@ class ResonanceEngine:
|
||||
"Set 'keep' to true if the comment feels authentic and matches the vibe.\n"
|
||||
"Set 'keep' to false only for clear spam, bots, UI buttons, or blacklist violations.\n"
|
||||
)
|
||||
|
||||
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
system = "You are a precise JSON filtering agent."
|
||||
# Fix: kwargs match query_llm signature EXACTLY to evade TypeError
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, system=system, format_json=True, images_b64=images_b64, max_tokens=600, temperature=0.1)
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
format_json=True,
|
||||
images_b64=images_b64,
|
||||
max_tokens=600,
|
||||
temperature=0.1,
|
||||
)
|
||||
if not response_dict or "response" not in response_dict:
|
||||
return
|
||||
|
||||
|
||||
response_text = response_dict["response"]
|
||||
# DEBUG
|
||||
logger.debug(f"DEBUG CONDENSER RAW: {response_text}")
|
||||
|
||||
|
||||
# Parse json gracefully
|
||||
if type(response_text) is str:
|
||||
clean_json = response_text.strip()
|
||||
@@ -360,7 +407,7 @@ class ResonanceEngine:
|
||||
else:
|
||||
# In case expect_json already returned a parsed list somehow, though extract_json returns str
|
||||
learned_comments = response_text
|
||||
|
||||
|
||||
# Filter the dict based on evaluations array
|
||||
if isinstance(learned_comments, dict):
|
||||
valid_list = []
|
||||
@@ -372,17 +419,25 @@ class ResonanceEngine:
|
||||
if not has_spam and keep:
|
||||
valid_list.append(ev.get("text"))
|
||||
learned_comments = valid_list
|
||||
|
||||
|
||||
if not isinstance(learned_comments, list):
|
||||
logger.error(f"🧠 [Comment Learning] Condenser failed to return a valid JSON structure: {learned_comments}")
|
||||
logger.error(
|
||||
f"🧠 [Comment Learning] Condenser failed to return a valid JSON structure: {learned_comments}"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
if not learned_comments:
|
||||
logger.info("🧠 [Comment Learning] Condenser rejected all scraped comments (did not align with vibe or hit blacklist).", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
"🧠 [Comment Learning] Condenser rejected all scraped comments (did not align with vibe or hit blacklist).",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"🧠 [Comment Learning] Condenser approved {len(learned_comments)} comments. Persisting to Qdrant...", extra={"color": f"{Fore.GREEN}"})
|
||||
|
||||
|
||||
logger.info(
|
||||
f"🧠 [Comment Learning] Condenser approved {len(learned_comments)} comments. Persisting to Qdrant...",
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
# 3. Store the passing comments into Qdrant
|
||||
comment_db = CommentMemoryDB()
|
||||
stored = 0
|
||||
@@ -391,9 +446,12 @@ class ResonanceEngine:
|
||||
logger.debug(f" 👉 Storing: '{c}'")
|
||||
comment_db.store_comment(text=c, vibe=vibe, author=author)
|
||||
stored += 1
|
||||
|
||||
|
||||
if stored > 0:
|
||||
logger.info(f"✅ [Comment Vector Sync] Successfully embedded {stored} high-vibe comments into memory.", extra={"color": f"{Fore.GREEN}"})
|
||||
|
||||
logger.info(
|
||||
f"✅ [Comment Vector Sync] Successfully embedded {stored} high-vibe comments into memory.",
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"🧠 [Comment Learning] Condenser failed: {e}")
|
||||
|
||||
@@ -4,18 +4,19 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HoneypotRadome:
|
||||
"""
|
||||
Project Dojo: The Anti-Test Sensor.
|
||||
Filters the Android XML Hierarchy to remove "invisible traps" and honeypots
|
||||
that Instagram uses to detect deterministic bots (e.g., 1x1 pixel buttons,
|
||||
Filters the Android XML Hierarchy to remove "invisible traps" and honeypots
|
||||
that Instagram uses to detect deterministic bots (e.g., 1x1 pixel buttons,
|
||||
off-screen elements with clickable=True).
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, display_width=1080, display_height=2400):
|
||||
self.display_width = display_width
|
||||
self.display_height = display_height
|
||||
self.bounds_pattern = re.compile(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]')
|
||||
self.bounds_pattern = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
|
||||
|
||||
def sanitize_xml(self, xml_string: str) -> str:
|
||||
"""
|
||||
@@ -23,19 +24,22 @@ class HoneypotRadome:
|
||||
Returns the sanitized XML string.
|
||||
"""
|
||||
try:
|
||||
# Android XML dumps often have multiple root nodes or formatting issues,
|
||||
# Android XML dumps often have multiple root nodes or formatting issues,
|
||||
# let's try reading it safely.
|
||||
# Handle potential encoding issues from dump_hierarchy
|
||||
clean_xml = xml_string.replace(' ', '').replace(' ', '')
|
||||
|
||||
clean_xml = xml_string.replace(" ", "").replace(" ", "")
|
||||
|
||||
root = ET.fromstring(clean_xml)
|
||||
removed_count = self._filter_node(root)
|
||||
|
||||
|
||||
if removed_count > 0:
|
||||
logger.info(f"🛡️ [Honeypot Radome] Stripped {removed_count} phantom nodes from view.", extra={"color": "\x1b[33m"})
|
||||
|
||||
logger.info(
|
||||
f"🛡️ [Honeypot Radome] Stripped {removed_count} phantom nodes from view.",
|
||||
extra={"color": "\x1b[33m"},
|
||||
)
|
||||
|
||||
# Convert back to string
|
||||
return ET.tostring(root, encoding='unicode')
|
||||
return ET.tostring(root, encoding="unicode")
|
||||
except Exception as e:
|
||||
logger.warning(f"🛡️ [Honeypot Radome] XML Parse failed, returning raw. Err: {e}")
|
||||
return xml_string
|
||||
@@ -43,17 +47,17 @@ class HoneypotRadome:
|
||||
def _filter_node(self, node: ET.Element) -> int:
|
||||
removed = 0
|
||||
children_to_remove = []
|
||||
|
||||
|
||||
for child in node:
|
||||
if self._is_honeypot(child):
|
||||
children_to_remove.append(child)
|
||||
removed += 1
|
||||
else:
|
||||
removed += self._filter_node(child)
|
||||
|
||||
|
||||
for child in children_to_remove:
|
||||
node.remove(child)
|
||||
|
||||
|
||||
return removed
|
||||
|
||||
def _is_honeypot(self, node: ET.Element) -> bool:
|
||||
@@ -63,33 +67,33 @@ class HoneypotRadome:
|
||||
bounds = node.get("bounds")
|
||||
if not bounds:
|
||||
return False
|
||||
|
||||
|
||||
match = self.bounds_pattern.match(bounds)
|
||||
if not match:
|
||||
return False
|
||||
|
||||
|
||||
x1, y1, x2, y2 = map(int, match.groups())
|
||||
width = x2 - x1
|
||||
height = y2 - y1
|
||||
|
||||
|
||||
is_clickable = node.get("clickable", "false").lower() == "true"
|
||||
|
||||
|
||||
# Rule 1: The Zero-Point Trap (Element is exactly on 0,0 with no dimensions)
|
||||
if x1 == 0 and y1 == 0 and x2 == 0 and y2 == 0:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 2: The Micro-Pixel Trap (Bot detectors often use 1x1 or 2x2 clickable overlay pixels)
|
||||
if is_clickable and width <= 2 and height <= 2:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 3: The Off-Screen Trap (Buttons rendered wildly out of bounds to bait mindless loops)
|
||||
if x1 >= self.display_width or y1 >= self.display_height:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 4: The Negative Coordinate Trap
|
||||
if x2 <= 0 or y2 <= 0:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 5: The Transparent Interceptor (Giant invisible overlays capturing touches)
|
||||
# If a clickable element takes up >90% of screen but has no text, description, or id, it's a touch trap.
|
||||
has_text = bool(node.get("text", ""))
|
||||
@@ -98,10 +102,10 @@ class HoneypotRadome:
|
||||
if is_clickable and width >= (self.display_width * 0.9) and height >= (self.display_height * 0.9):
|
||||
if not has_text and not has_desc and not has_id:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 6: Android Accessibility Trap (A node is clickable but explicitly not visible)
|
||||
# Sometimes uiautomator injects 'visible-to-user' manually, or it has bounds but isn't enabled.
|
||||
if is_clickable and node.get("visible-to-user", "true").lower() == "false":
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
@@ -293,7 +293,7 @@ class SituationalAwarenessEngine:
|
||||
if not xml_dump or not isinstance(xml_dump, str):
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
xml_lower = xml_dump.lower()
|
||||
xml_dump.lower()
|
||||
|
||||
blocked_markers = [
|
||||
"try again later",
|
||||
@@ -626,7 +626,6 @@ class SituationalAwarenessEngine:
|
||||
from GramAddict.core.exceptions import ActionBlockedError
|
||||
|
||||
failed_this_session = set()
|
||||
cleared_something = False
|
||||
|
||||
last_situation = None
|
||||
situation_attempts = 0
|
||||
@@ -700,7 +699,6 @@ class SituationalAwarenessEngine:
|
||||
return True
|
||||
else:
|
||||
self._execute_escape(action)
|
||||
cleared_something = True
|
||||
|
||||
# ── VERIFY ──
|
||||
post_xml = self.device.dump_hierarchy()
|
||||
|
||||
@@ -4,6 +4,7 @@ from time import sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ghost_type(device, text: str):
|
||||
"""
|
||||
Tesla Stealth Ghost Keyboard.
|
||||
@@ -12,59 +13,60 @@ def ghost_type(device, text: str):
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
|
||||
logger.info(f"⌨️ [Ghost Keyboard] Initiating stealth injection ({len(text)} chars)...")
|
||||
|
||||
|
||||
# We slice text into variable-sized human bursts
|
||||
chunks = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if random.random() < 0.15:
|
||||
chunk_size = 1 # single letter hunting
|
||||
chunk_size = 1 # single letter hunting
|
||||
else:
|
||||
chunk_size = random.randint(2, 6) # fluid typing bursts
|
||||
|
||||
chunks.append(text[i:i+chunk_size])
|
||||
chunk_size = random.randint(2, 6) # fluid typing bursts
|
||||
|
||||
chunks.append(text[i : i + chunk_size])
|
||||
i += chunk_size
|
||||
|
||||
for idx, chunk in enumerate(chunks):
|
||||
# 5% chance of a typo if it's an alphabetical chunk
|
||||
if random.random() < 0.05 and len(chunk) >= 2 and chunk[-1].isalpha():
|
||||
typo_letter = random.choice('abcdefghijklmnopqrstuvwxyz')
|
||||
typo_letter = random.choice("abcdefghijklmnopqrstuvwxyz")
|
||||
# Add typo instead of actual last letter
|
||||
typo_chunk = chunk[:-1] + typo_letter
|
||||
_adb_inject_text(device, typo_chunk)
|
||||
|
||||
|
||||
# Realize mistake
|
||||
sleep(random.uniform(0.2, 0.45))
|
||||
|
||||
|
||||
# Send Backspace (KEYCODE_DEL = 67)
|
||||
device.shell("input keyevent 67")
|
||||
sleep(random.uniform(0.1, 0.25))
|
||||
|
||||
|
||||
# Inject the correct character
|
||||
_adb_inject_text(device, chunk[-1])
|
||||
else:
|
||||
_adb_inject_text(device, chunk)
|
||||
|
||||
|
||||
# Realistic pause between semantic bursts (humans think while typing)
|
||||
if chunk.endswith((" ", ".", ",", "!", "?")):
|
||||
sleep(random.uniform(0.2, 0.5))
|
||||
else:
|
||||
sleep(random.uniform(0.05, 0.18))
|
||||
|
||||
|
||||
logger.debug("⌨️ [Ghost Keyboard] Injection complete.")
|
||||
|
||||
|
||||
|
||||
def _adb_inject_text(device, text: str):
|
||||
if not text:
|
||||
return
|
||||
|
||||
|
||||
# For Android `input text`, spaces must be mapped to %s
|
||||
# Single quotes need to be bash escaped since we wrap the string in ''
|
||||
# Special characters like & | > < \ ( ) { } ! must be carefully handled.
|
||||
# The safest way is to let shell loop over characters or strictly replace.
|
||||
safe_text = text.replace(" ", "%s").replace("'", "\\'")
|
||||
|
||||
|
||||
# Send through Android's native InputManager
|
||||
try:
|
||||
device.shell(["input", "text", safe_text])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,18 +13,18 @@ logger = logging.getLogger(__name__)
|
||||
class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
Decentralized Markov state-channel for P2P knowledge sharing.
|
||||
|
||||
|
||||
Manages 'Pheromones' (successful UI transitions and interactions)
|
||||
and 'BannedPaths' (failed attempts) across bot sessions.
|
||||
|
||||
This creates a Fleet Learning effect: every session learns from
|
||||
|
||||
This creates a Fleet Learning effect: every session learns from
|
||||
every previous session's successes and failures.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.username = username
|
||||
super().__init__(collection_name="gramaddict_swarm_pheromones", vector_size=4)
|
||||
|
||||
|
||||
def emit_pheromone(self, path_hash: str, outcome: str):
|
||||
"""
|
||||
Broadcasting a successful UI transition or interaction to the fleet memory.
|
||||
@@ -32,7 +32,7 @@ class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
if not self.is_connected or not self.client:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
self.upsert_point(
|
||||
seed_string=f"{path_hash}_{outcome}",
|
||||
@@ -44,12 +44,11 @@ class SwarmProtocol(QdrantBase):
|
||||
"timestamp": time.time(),
|
||||
"count": 1,
|
||||
},
|
||||
log_success=f"🌐 [Swarm] ⚡ Pheromone emitted: {path_hash[:16]} → {outcome}"
|
||||
log_success=f"🌐 [Swarm] ⚡ Pheromone emitted: {path_hash[:16]} → {outcome}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swarm] Pheromone emit failed: {e}")
|
||||
|
||||
|
||||
def query_consensus(self, path_hash: str) -> Optional[str]:
|
||||
"""
|
||||
Queries the swarm for historical outcomes of a specific path.
|
||||
@@ -57,32 +56,22 @@ class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
if not self.is_connected or not self.client:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
points, _ = self.client.scroll(
|
||||
collection_name=self.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="path_hash",
|
||||
match=MatchValue(value=path_hash)
|
||||
)
|
||||
]
|
||||
),
|
||||
scroll_filter=Filter(must=[FieldCondition(key="path_hash", match=MatchValue(value=path_hash))]),
|
||||
limit=1,
|
||||
with_payload=True,
|
||||
)
|
||||
|
||||
|
||||
if points:
|
||||
outcome = points[0].payload.get("outcome")
|
||||
logger.info(
|
||||
f"🌐 [Swarm] Consensus for {path_hash[:16]}: {outcome}",
|
||||
extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
logger.info(f"🌐 [Swarm] Consensus for {path_hash[:16]}: {outcome}", extra={"color": f"{Fore.CYAN}"})
|
||||
return outcome
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swarm] Consensus query failed: {e}")
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def sync_banned_paths(self, banned_paths_db):
|
||||
@@ -92,22 +81,15 @@ class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
if not self.is_connected or not self.client:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
points, _ = self.client.scroll(
|
||||
collection_name=self.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="outcome",
|
||||
match=MatchValue(value="banned")
|
||||
)
|
||||
]
|
||||
),
|
||||
scroll_filter=Filter(must=[FieldCondition(key="outcome", match=MatchValue(value="banned"))]),
|
||||
limit=100,
|
||||
with_payload=True,
|
||||
)
|
||||
|
||||
|
||||
synced = 0
|
||||
for pt in points:
|
||||
payload = pt.payload or {}
|
||||
@@ -115,11 +97,10 @@ class SwarmProtocol(QdrantBase):
|
||||
if path and banned_paths_db:
|
||||
banned_paths_db.ban(path, "swarm_synced", reason="Synced from fleet memory")
|
||||
synced += 1
|
||||
|
||||
|
||||
if synced > 0:
|
||||
logger.info(
|
||||
f"🌐 [Swarm] Synced {synced} banned paths from fleet memory.",
|
||||
extra={"color": f"{Fore.CYAN}"}
|
||||
f"🌐 [Swarm] Synced {synced} banned paths from fleet memory.", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swarm] Banned path sync failed: {e}")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _humanized_scroll_down(device):
|
||||
# Same as bot_flow._humanized_scroll but strictly downward
|
||||
info = device.get_info()
|
||||
@@ -16,29 +18,36 @@ def _humanized_scroll_down(device):
|
||||
duration = random.uniform(0.08, 0.12)
|
||||
device.swipe(start_x, start_y, start_x, end_y, duration)
|
||||
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):
|
||||
|
||||
def _run_zero_latency_unfollow_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack
|
||||
):
|
||||
"""
|
||||
Executes the autonomous Unfollow logic in the Zero-Latency architecture.
|
||||
Assumes the bot is already at the "FollowingList" UI state.
|
||||
"""
|
||||
logger.info(f"🧠 [Unfollow Engine] Initiating cleanup routine in {current_target}...", extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"})
|
||||
|
||||
logger.info(
|
||||
f"🧠 [Unfollow Engine] Initiating cleanup routine in {current_target}...",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"},
|
||||
)
|
||||
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
|
||||
|
||||
unfollow_limit = int(getattr(configs.args, "total_unfollows_limit", 50))
|
||||
failed_scrolls = 0
|
||||
total_unfollowed_this_session = 0
|
||||
|
||||
from GramAddict.core.bot_flow import dump_ui_state, _humanized_click
|
||||
|
||||
from GramAddict.core.bot_flow import _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'):
|
||||
if not hasattr(session_state, "totalUnfollowed"):
|
||||
session_state.totalUnfollowed = 0
|
||||
|
||||
|
||||
while not dopamine.is_app_session_over():
|
||||
# Check global limit tuple logic
|
||||
limit_val = session_state.check_limit(SessionState.Limit.UNFOLLOWS)
|
||||
@@ -48,90 +57,104 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
elif limit_val is True:
|
||||
logger.info("🛑 Unfollow limit reached for session. Yielding control.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
if total_unfollowed_this_session >= unfollow_limit:
|
||||
logger.info("🛑 Configured unfollow limit reached. Yielding control.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
logger.info("🛑 Configured unfollow limit reached. Yielding control.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
try:
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
|
||||
# Smart Unfollow Phase 1: Find user rows instead of just clicking "Following"
|
||||
nodes = telepathic._extract_semantic_nodes(xml_dump, "find user profile rows in list", threshold=0.7)
|
||||
|
||||
|
||||
action_taken = False
|
||||
for node in nodes:
|
||||
if node.get("skip") or not node.get("bounds"):
|
||||
continue
|
||||
|
||||
|
||||
# 1. Tap the profile row to navigate to their page
|
||||
_humanized_click(device, node["x"], node["y"])
|
||||
action_taken = True
|
||||
logger.debug(f"👆 Tapped profile row at ({node['x']}, {node['y']})")
|
||||
|
||||
|
||||
# Wait for profile to load
|
||||
random_sleep(1.5, 2.5)
|
||||
profile_xml = device.dump_hierarchy()
|
||||
|
||||
|
||||
# 2. Close Friend Guard
|
||||
profile_text = profile_xml.lower()
|
||||
if "enge freunde" in profile_text or "close friend" in profile_text:
|
||||
logger.info("💚 [Anti-Friend] Profile is a Close Friend. Skipping unfollow.", extra={"color": Fore.GREEN})
|
||||
logger.info(
|
||||
"💚 [Anti-Friend] Profile is a Close Friend. Skipping unfollow.", extra={"color": Fore.GREEN}
|
||||
)
|
||||
device.back()
|
||||
random_sleep(0.8, 1.5)
|
||||
break # Go next in loop
|
||||
|
||||
break # Go next in loop
|
||||
|
||||
# 3. Resonance Evaluation
|
||||
resonance = cognitive_stack.get("resonance")
|
||||
res_score = 0.5
|
||||
if resonance:
|
||||
# Parse the description from the XML (rough pass, ResonanceEngine handles noise)
|
||||
res_score = resonance.calculate_resonance({"description": profile_xml})
|
||||
|
||||
|
||||
# 4. Decision: If < 0.4, Unfollow. Else Keep.
|
||||
if res_score < 0.4:
|
||||
logger.info(f"🗑️ [Smart Cleanup] Resonance is low ({res_score:.2f}). Unfollowing.", extra={"color": Fore.YELLOW})
|
||||
|
||||
logger.info(
|
||||
f"🗑️ [Smart Cleanup] Resonance is low ({res_score:.2f}). Unfollowing.",
|
||||
extra={"color": Fore.YELLOW},
|
||||
)
|
||||
|
||||
# Find 'Following' button on their profile
|
||||
following_nodes = telepathic._extract_semantic_nodes(profile_xml, "find 'Following' button", threshold=0.7)
|
||||
following_nodes = telepathic._extract_semantic_nodes(
|
||||
profile_xml, "find 'Following' button", threshold=0.7
|
||||
)
|
||||
if following_nodes and not following_nodes[0].get("skip"):
|
||||
f_node = following_nodes[0]
|
||||
_humanized_click(device, f_node["x"], f_node["y"])
|
||||
random_sleep(1.0, 2.0)
|
||||
|
||||
|
||||
# Find 'Unfollow' confirm
|
||||
confirm_xml = device.dump_hierarchy()
|
||||
confirm_nodes = telepathic._extract_semantic_nodes(confirm_xml, "find 'Unfollow' confirmation button", threshold=0.8)
|
||||
|
||||
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"])
|
||||
random_sleep(0.8, 1.5)
|
||||
|
||||
|
||||
logger.info("✅ [Unfollow Engine] Unfollowed a user.", extra={"color": Fore.GREEN})
|
||||
session_state.totalUnfollowed += 1
|
||||
total_unfollowed_this_session += 1
|
||||
failed_scrolls = 0
|
||||
dopamine.boredom += random.uniform(1.0, 3.0)
|
||||
dopamine.boredom += random.uniform(1.0, 3.0)
|
||||
else:
|
||||
logger.info(f"✨ [Smart Cleanup] Resonance is high ({res_score:.2f}). Keeping subscription.", extra={"color": Fore.MAGENTA})
|
||||
logger.info(
|
||||
f"✨ [Smart Cleanup] Resonance is high ({res_score:.2f}). Keeping subscription.",
|
||||
extra={"color": Fore.MAGENTA},
|
||||
)
|
||||
failed_scrolls = 0
|
||||
|
||||
|
||||
# 5. Always return to the Following list
|
||||
device.back()
|
||||
random_sleep(1.0, 2.0)
|
||||
break
|
||||
|
||||
|
||||
if not action_taken:
|
||||
# No following buttons in view, scroll down to find more
|
||||
_humanized_scroll_down(device)
|
||||
dopamine.boredom += 0.5
|
||||
failed_scrolls += 1
|
||||
|
||||
|
||||
if failed_scrolls > 5:
|
||||
logger.warning("⚠️ [Unfollow Engine] No 'Following' buttons found after multiple scrolls. Aborting or reaching bottom.")
|
||||
logger.warning(
|
||||
"⚠️ [Unfollow Engine] No 'Following' buttons found after multiple scrolls. Aborting or reaching bottom."
|
||||
)
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
if dopamine.wants_to_change_feed():
|
||||
logger.info("🧠 [Unfollow Engine] Desire to clean up following list satisfied. Navigating elsewhere.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
@@ -141,6 +164,6 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
_humanized_scroll_down(device)
|
||||
failed_scrolls += 1
|
||||
if failed_scrolls > 3:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import logging
|
||||
import random
|
||||
import requests
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
from colorama import Fore, Style
|
||||
from packaging.version import parse as parse_version
|
||||
|
||||
from GramAddict.core.version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sanitize_text(text):
|
||||
return (text or "").strip()
|
||||
|
||||
|
||||
def random_sleep(inf=1.0, sup=3.0, modulable=True):
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
configs = Config()
|
||||
try:
|
||||
multiplier = float(getattr(configs.args, "speed_multiplier", 1.0))
|
||||
@@ -23,21 +24,26 @@ def random_sleep(inf=1.0, sup=3.0, modulable=True):
|
||||
delay = random.uniform(inf, sup) / (multiplier if modulable else 1.0)
|
||||
sleep(max(delay, 0.2))
|
||||
|
||||
|
||||
def config_examples():
|
||||
logger.debug("Config examples handled by documentation.")
|
||||
|
||||
|
||||
def check_if_updated():
|
||||
logger.info(f"GramAddict v.{__version__}", extra={"color": f"{Style.BRIGHT}{Fore.MAGENTA}"})
|
||||
|
||||
|
||||
def get_instagram_version(device):
|
||||
try:
|
||||
output = device.shell(f"dumpsys package {device.app_id}").output
|
||||
import re
|
||||
|
||||
version_match = re.findall("versionName=(\\S+)", output)
|
||||
return version_match[0] if version_match else "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def close_instagram(device, force_kill=False):
|
||||
if force_kill:
|
||||
logger.info("Force-closing Instagram app to clean session state.")
|
||||
@@ -52,6 +58,7 @@ def close_instagram(device, force_kill=False):
|
||||
except Exception as e:
|
||||
logger.debug(f"Error pressing home: {e}")
|
||||
|
||||
|
||||
def open_instagram(device, force_restart=False):
|
||||
if force_restart:
|
||||
logger.info("Opening Instagram app (Fresh Start).")
|
||||
@@ -64,16 +71,21 @@ def open_instagram(device, force_restart=False):
|
||||
random_sleep(1, 2, modulable=False)
|
||||
return True
|
||||
|
||||
|
||||
def set_time_delta(args):
|
||||
args.time_delta_session = random.randint(-300, 300)
|
||||
|
||||
|
||||
def wait_for_next_session(time_left, session_state, sessions, device):
|
||||
logger.info(f"Waiting {time_left} until next working hours.")
|
||||
sleep(60)
|
||||
|
||||
|
||||
def get_value(count, name, default=0):
|
||||
if count is None: return default
|
||||
if isinstance(count, (int, float)): return count
|
||||
if count is None:
|
||||
return default
|
||||
if isinstance(count, (int, float)):
|
||||
return count
|
||||
try:
|
||||
if "-" in str(count):
|
||||
parts = str(count).split("-")
|
||||
@@ -82,15 +94,16 @@ def get_value(count, name, default=0):
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
"""
|
||||
Checks if the current view contains an advertisement using autonomous learning.
|
||||
|
||||
|
||||
If a cognitive_stack is provided, it uses the Telepathic Engine for
|
||||
semantic classification (Zero-Latency vector lookup).
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
if cognitive_stack:
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
@@ -103,18 +116,15 @@ def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
# --- Legacy Fallback ---
|
||||
# Regex word boundaries prevent false positives like 'brunette_abroad'
|
||||
AD_RESOURCE_IDS = [
|
||||
'com.instagram.android:id/ad_cta_button',
|
||||
'com.instagram.android:id/sponsored_label',
|
||||
'com.instagram.android:id/clips_single_image_ads_media_content',
|
||||
'com.instagram.android:id/ads_carousel_progress_bar',
|
||||
'com.instagram.android:id/ad_not_interested_button'
|
||||
"com.instagram.android:id/ad_cta_button",
|
||||
"com.instagram.android:id/sponsored_label",
|
||||
"com.instagram.android:id/clips_single_image_ads_media_content",
|
||||
"com.instagram.android:id/ads_carousel_progress_bar",
|
||||
"com.instagram.android:id/ad_not_interested_button",
|
||||
]
|
||||
|
||||
AD_MARKERS = [
|
||||
r'\b(sponsored|ad|advertisement)\b',
|
||||
r'\b(gesponsert|anzeige|werbung)\b'
|
||||
]
|
||||
|
||||
|
||||
AD_MARKERS = [r"\b(sponsored|ad|advertisement)\b", r"\b(gesponsert|anzeige|werbung)\b"]
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
for node in root.iter("node"):
|
||||
|
||||
@@ -4,16 +4,18 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZeroLatencyEngine:
|
||||
"""
|
||||
The Zero-Latency Executor
|
||||
This engine receives a pre-compiled heuristic (Regex/XPath) from the memory cache
|
||||
and executes it against the local XML layout in under 5ms.
|
||||
and executes it against the local XML layout in under 5ms.
|
||||
It is completely deterministic. No LLM calls happen here.
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
|
||||
|
||||
def evaluate_heuristic(self, rule: dict, context_xml: str):
|
||||
"""
|
||||
Executes a compiled heuristic rule against the provided XML dump.
|
||||
@@ -22,20 +24,20 @@ class ZeroLatencyEngine:
|
||||
"""
|
||||
if not rule or not context_xml:
|
||||
return None
|
||||
|
||||
|
||||
rule_type = rule.get("rule_type", "regex")
|
||||
target_attr = rule.get("target_attribute", "text")
|
||||
pattern = rule.get("pattern", "")
|
||||
|
||||
|
||||
if not pattern:
|
||||
return None
|
||||
|
||||
try:
|
||||
root = ET.fromstring(context_xml)
|
||||
|
||||
|
||||
if rule_type == "regex":
|
||||
# Remove (?i) if present because we compile with re.IGNORECASE anyway
|
||||
clean_pattern = pattern.replace('(?i)', '')
|
||||
clean_pattern = pattern.replace("(?i)", "")
|
||||
regex = re.compile(clean_pattern, re.IGNORECASE)
|
||||
for node in root.iter("node"):
|
||||
val = ""
|
||||
@@ -55,17 +57,17 @@ class ZeroLatencyEngine:
|
||||
match = regex.search(val)
|
||||
if match:
|
||||
if len(match.groups()) > 0:
|
||||
return match.group(1) # Return captured group (e.g., username)
|
||||
return True # Return boolean existence (e.g. is_ad)
|
||||
|
||||
return match.group(1) # Return captured group (e.g., username)
|
||||
return True # Return boolean existence (e.g. is_ad)
|
||||
|
||||
elif rule_type == "xpath":
|
||||
# Basic xpath parsing over ET
|
||||
nodes = root.findall(pattern)
|
||||
if nodes:
|
||||
return nodes[0].attrib.get(target_attr, "")
|
||||
|
||||
return False # Rule ran but found nothing
|
||||
|
||||
|
||||
return False # Rule ran but found nothing
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"ZeroLatencyEngine failed to evaluate rule {pattern}: {e}")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user