diff --git a/GramAddict/core/unfollow_engine.py b/GramAddict/core/unfollow_engine.py index 828806b..dca055c 100644 --- a/GramAddict/core/unfollow_engine.py +++ b/GramAddict/core/unfollow_engine.py @@ -56,38 +56,70 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses try: xml_dump = device.dump_hierarchy() - # Use Telepathic Engine to explicitly locate existing "Following" buttons in lists - nodes = telepathic._extract_semantic_nodes(xml_dump, "find 'Following' buttons next to usernames", threshold=0.7) + # 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: - # Basic validation it's an interactive button if node.get("skip") or not node.get("bounds"): continue - # Tap the first valid following button we see + # 1. Tap the profile row to navigate to their page _humanized_click(device, node["x"], node["y"]) action_taken = True - logger.debug(f"👆 Tapped following button at ({node['x']}, {node['y']})") + logger.debug(f"👆 Tapped profile row at ({node['x']}, {node['y']})") - # Check for confirmation dialog ("Unfollow @username?") - random_sleep(1.0, 2.0) - confirm_xml = device.dump_hierarchy() - confirm_nodes = telepathic._extract_semantic_nodes(confirm_xml, "find 'Unfollow' confirmation button", threshold=0.8) + # Wait for profile to load + random_sleep(1.5, 2.5) + profile_xml = device.dump_hierarchy() - if confirm_nodes and not confirm_nodes[0].get("skip"): - c_node = confirm_nodes[0] - _humanized_click(device, c_node["x"], c_node["y"]) + # 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}) + device.back() random_sleep(0.8, 1.5) + break # Go next in loop - logger.info("✅ [Unfollow Engine] Unfollowed a user in list.", extra={"color": Fore.GREEN}) - session_state.totalUnfollowed += 1 - total_unfollowed_this_session += 1 - failed_scrolls = 0 - - # Unfollow cost logic - dopamine.boredom += random.uniform(1.0, 3.0) - random_sleep(1.5, 3.0) + # 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}) + + # Find 'Following' button on their profile + 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) + + 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) + else: + 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: diff --git a/tests/integration/test_unfollow_loop.py b/tests/integration/test_unfollow_loop.py index 546a4ac..1dea23b 100644 --- a/tests/integration/test_unfollow_loop.py +++ b/tests/integration/test_unfollow_loop.py @@ -24,9 +24,13 @@ def unfollow_mock_dependencies(): dopamine.wants_to_change_feed.return_value = False dopamine.boredom = 0.0 + resonance = MagicMock() + resonance.calculate_resonance.return_value = 0.2 + cognitive_stack = { "telepathic": telepathic, "dopamine": dopamine, + "resonance": resonance, } return device, zero_engine, nav_graph, configs, session_state, cognitive_stack @@ -36,21 +40,29 @@ def test_unfollow_engine_basic_loop(unfollow_mock_dependencies): telepathic = cognitive_stack["telepathic"] - # First node gives a "Following" button + # 1. Finds profile row + # 2. Finds following button on profile + # 3. Finds confirm dialog telepathic._extract_semantic_nodes.side_effect = [ - [{"x": 100, "y": 200, "skip": False, "bounds": "..."}], # following button - [{"x": 150, "y": 250, "skip": False}], # confirm dialog + [{"semantic_string": "Profile Row", "x": 100, "y": 200, "skip": False, "bounds": "..."}], + [{"semantic_string": "Following Button", "x": 150, "y": 250, "skip": False}], + [{"semantic_string": "Unfollow Confirm", "x": 200, "y": 300, "skip": False}], [], # second iteration [] ] with patch("GramAddict.core.unfollow_engine._humanized_scroll_down") as mock_scroll, \ patch("GramAddict.core.bot_flow.sleep"), \ + patch("GramAddict.core.bot_flow.random_sleep"), \ + patch("GramAddict.core.utils.random_sleep"), \ patch("GramAddict.core.bot_flow._humanized_click") as mock_click: + device.dump_hierarchy.return_value = '' + res = _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, session_state, "FollowingList", cognitive_stack) - assert mock_click.call_count == 2 # Clicked following THEN clicked confirm + # Clicked profile -> following button -> confirm + assert mock_click.call_count == 3 assert session_state.totalUnfollowed == 1 assert res == "FEED_EXHAUSTED" diff --git a/tests/unit/test_unfollow_engine.py b/tests/unit/test_unfollow_engine.py index cac9f1e..7767754 100644 --- a/tests/unit/test_unfollow_engine.py +++ b/tests/unit/test_unfollow_engine.py @@ -16,12 +16,15 @@ class TestUnfollowEngine: self.mock_dopamine = MagicMock() self.mock_dopamine.is_app_session_over.return_value = False self.mock_dopamine.wants_to_change_feed.return_value = False - # default boredom self.mock_dopamine.boredom = 0.0 + self.mock_resonance = MagicMock() + self.mock_resonance.calculate_resonance.return_value = 0.2 + self.cognitive_stack = { "telepathic": self.mock_telepathic, "dopamine": self.mock_dopamine, + "resonance": self.mock_resonance, } self.mock_configs = MagicMock() @@ -33,27 +36,39 @@ class TestUnfollowEngine: self.logger = logging.getLogger("test") - def test_unfollow_loop_success(self, monkeypatch): + def test_unfollow_loop_unfollows_low_resonance(self, monkeypatch): """ - Happy path: Finds 'Following' button -> Clicks it -> Finds 'Unfollow' confirmation -> Clicks it -> Increments totalUnfollowed. - Then dopamine signals change of feed. + Happy path (Smart Unfollow): + 1. Clicks profile row. + 2. Dumps profile UI. + 3. Resonance is low (< 0.4). + 4. Clicks 'Following' button on profile. + 5. Clicks 'Unfollow' confirm. + 6. Clicks back. """ def fake_extract_semantic_nodes(xml, intent, **kwargs): - if "Unfollow" in intent: + if "profile rows" in intent: + return [{"semantic_string": "Profile Row", "x": 100, "y": 200, "bounds": "[50,150]", "skip": False}] + elif "Unfollow" in intent: return [{"semantic_string": "Unfollow Confirmation", "x": 500, "y": 1500, "bounds": "[100,200]", "skip": False}] else: return [{"semantic_string": "Following Button", "x": 900, "y": 600, "bounds": "[100,200]", "skip": False}] self.mock_telepathic._extract_semantic_nodes.side_effect = fake_extract_semantic_nodes self.mock_dopamine.wants_to_change_feed.side_effect = [True] + self.mock_device.dump_hierarchy.return_value = '' - # Patch local imports inside the bot_flow namespace since they are locally imported + # Patch local imports inside the bot_flow / unfollow_engine namespace mock_sleep = MagicMock() mock_click = MagicMock() import GramAddict.core.bot_flow monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", mock_sleep) + monkeypatch.setattr(GramAddict.core.bot_flow, "random_sleep", mock_sleep) monkeypatch.setattr(GramAddict.core.bot_flow, "_humanized_click", mock_click) + import GramAddict.core.utils + monkeypatch.setattr(GramAddict.core.utils, "random_sleep", mock_sleep) + result = _run_zero_latency_unfollow_loop( self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack ) @@ -62,12 +77,88 @@ class TestUnfollowEngine: assert result == "BOREDOM_CHANGE_FEED" assert self.mock_session_state.totalUnfollowed == 1 - # Assert humanized clicks were logged correctly - assert mock_click.call_count == 2 + # Assert humanized clicks were logged correctly (Profile Row -> Following Button -> Unfollow Confirm) + assert mock_click.call_count == 3 mock_click.assert_has_calls([ + call(self.mock_device, 100, 200), call(self.mock_device, 900, 600), call(self.mock_device, 500, 1500) ]) + assert self.mock_device.back.call_count == 1 + + def test_unfollow_loop_keeps_high_resonance_profile(self, monkeypatch): + """ + If resonance is high, we keep following the account and go back to the list. + """ + self.mock_resonance.calculate_resonance.return_value = 0.8 + + def fake_extract_semantic_nodes(xml, intent, **kwargs): + if "profile rows" in intent: + return [{"semantic_string": "Profile Row", "x": 100, "y": 200, "bounds": "[50,150]", "skip": False}] + return [] + + self.mock_telepathic._extract_semantic_nodes.side_effect = fake_extract_semantic_nodes + self.mock_dopamine.wants_to_change_feed.side_effect = [True] + self.mock_device.dump_hierarchy.return_value = '' + + mock_sleep = MagicMock() + mock_click = MagicMock() + import GramAddict.core.bot_flow + monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", mock_sleep) + monkeypatch.setattr(GramAddict.core.bot_flow, "random_sleep", mock_sleep) + monkeypatch.setattr(GramAddict.core.bot_flow, "_humanized_click", mock_click) + import GramAddict.core.utils + monkeypatch.setattr(GramAddict.core.utils, "random_sleep", mock_sleep) + + result = _run_zero_latency_unfollow_loop( + self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack + ) + + assert result == "BOREDOM_CHANGE_FEED" + assert self.mock_session_state.totalUnfollowed == 0 + + # Clicked profile, then went back (no unfollow clicks) + assert mock_click.call_count == 1 + mock_click.assert_has_calls([call(self.mock_device, 100, 200)]) + assert self.mock_device.back.call_count == 1 + + def test_unfollow_loop_skips_close_friend(self, monkeypatch): + """ + If 'enge freunde' is in the XML, skip resonance eval, skip unfollowing, just go back. + """ + def fake_extract_semantic_nodes(xml, intent, **kwargs): + if "profile rows" in intent: + return [{"semantic_string": "Profile Row", "x": 100, "y": 200, "bounds": "[50,150]", "skip": False}] + return [] + + self.mock_telepathic._extract_semantic_nodes.side_effect = fake_extract_semantic_nodes + self.mock_dopamine.wants_to_change_feed.side_effect = [True] + + # Mock XML with Enge Freunde + self.mock_device.dump_hierarchy.side_effect = [ + '', # First dump on list + '' # Second dump on profile + ] + + mock_sleep = MagicMock() + mock_click = MagicMock() + import GramAddict.core.bot_flow + monkeypatch.setattr(GramAddict.core.bot_flow, "sleep", mock_sleep) + monkeypatch.setattr(GramAddict.core.bot_flow, "random_sleep", mock_sleep) + monkeypatch.setattr(GramAddict.core.bot_flow, "_humanized_click", mock_click) + import GramAddict.core.utils + monkeypatch.setattr(GramAddict.core.utils, "random_sleep", mock_sleep) + + result = _run_zero_latency_unfollow_loop( + self.mock_device, None, None, self.mock_configs, self.mock_session_state, "FollowingList", self.cognitive_stack + ) + + assert result == "BOREDOM_CHANGE_FEED" + assert self.mock_session_state.totalUnfollowed == 0 + assert mock_click.call_count == 1 + assert self.mock_device.back.call_count == 1 + # Resonance shouldn't even be called for close friends + self.mock_resonance.calculate_resonance.assert_not_called() def test_unfollow_loop_scrolls_if_empty(self, monkeypatch): """