soropy 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
soropy/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """
2
+ SoroPy - Professional Soroush Plus Web Client Library
3
+ =====================================================
4
+
5
+ A comprehensive, production-grade Python library for automating
6
+ Soroush Plus (splus.ir) web messenger.
7
+
8
+ Features:
9
+ - Multi-account simultaneous sessions
10
+ - Headless browser support
11
+ - Auto-reply with rule engine and duplicate prevention
12
+ - Chat extraction, messaging, channel posting
13
+ - Contact management
14
+ - Persistent session management
15
+ - Thread-safe design
16
+
17
+ Basic Usage:
18
+ >>> from soropy import SoroushClient
19
+ >>> client = SoroushClient("+989123456789")
20
+ >>> client.login()
21
+ >>> chats = client.get_chats()
22
+ >>> client.send_message("علی", "سلام!")
23
+ >>> client.close()
24
+
25
+ Multi-Account:
26
+ >>> from soropy import MultiAccountManager
27
+ >>> manager = MultiAccountManager()
28
+ >>> manager.add_account("+989123456789")
29
+ >>> manager.add_account("+989187654321")
30
+ >>> manager.login_all()
31
+ """
32
+
33
+ __version__ = "1.0.0"
34
+ __author__ = "SoroPy Team"
35
+ __license__ = "MIT"
36
+
37
+ from soropy.client import SoroushClient
38
+ from soropy.auto_reply import AutoReplyEngine, ReplyRule
39
+ from soropy.types import (
40
+ ChatInfo,
41
+ ContactInfo,
42
+ MessageInfo,
43
+ UnreadChat,
44
+ SendResult,
45
+ LoginStatus,
46
+ )
47
+ from soropy.exceptions import (
48
+ SoroPyError,
49
+ LoginError,
50
+ SessionError,
51
+ ChatError,
52
+ MessageError,
53
+ ContactError,
54
+ ChannelError,
55
+ BrowserError,
56
+ TimeoutError as SoroPyTimeoutError,
57
+ )
58
+ from soropy.session import SessionManager
59
+ from soropy.message_tracker import MessageTracker
60
+
61
+ # Multi-account manager
62
+ from soropy.multi import MultiAccountManager
63
+
64
+ __all__ = [
65
+ "SoroushClient",
66
+ "MultiAccountManager",
67
+ "AutoReplyEngine",
68
+ "ReplyRule",
69
+ "ChatInfo",
70
+ "ContactInfo",
71
+ "MessageInfo",
72
+ "UnreadChat",
73
+ "SendResult",
74
+ "LoginStatus",
75
+ "SessionManager",
76
+ "MessageTracker",
77
+ "SoroPyError",
78
+ "LoginError",
79
+ "SessionError",
80
+ "ChatError",
81
+ "MessageError",
82
+ "ContactError",
83
+ "ChannelError",
84
+ "BrowserError",
85
+ "SoroPyTimeoutError",
86
+ ]
soropy/auth.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ Authentication flow: phone entry → verification code → chat page wait.
3
+ """
4
+
5
+ import time
6
+ from typing import Optional, Callable
7
+
8
+ from selenium.webdriver.remote.webdriver import WebDriver
9
+ from selenium.webdriver.common.by import By
10
+ from selenium.webdriver.common.keys import Keys
11
+ from selenium.webdriver.support.ui import WebDriverWait
12
+ from selenium.webdriver.support import expected_conditions as EC
13
+
14
+ from soropy import constants as C
15
+ from soropy.utils import (
16
+ get_logger,
17
+ wait_and_find,
18
+ safe_click,
19
+ safe_type,
20
+ wait_page_load,
21
+ )
22
+ from soropy.types import LoginStatus
23
+ from soropy.exceptions import LoginError
24
+
25
+ logger = get_logger("soropy.auth")
26
+
27
+
28
+ class Authenticator:
29
+ """Encapsulates the multi-step Soroush Plus login process."""
30
+
31
+ def __init__(self, driver: WebDriver):
32
+ self._driver = driver
33
+
34
+ # ── public ─────────────────────────────────────────
35
+
36
+ def is_logged_in(self) -> bool:
37
+ """Check whether the current page shows the chat list."""
38
+ time.sleep(3)
39
+ wait_page_load(self._driver, C.PAGE_LOAD_TIMEOUT)
40
+ for sel in C.SEL_LOGGED_IN_INDICATORS:
41
+ try:
42
+ if self._driver.find_elements(By.CSS_SELECTOR, sel):
43
+ logger.info("Already logged in (matched %s)", sel)
44
+ return True
45
+ except Exception:
46
+ continue
47
+ return False
48
+
49
+ def login(
50
+ self,
51
+ phone: str,
52
+ code_callback: Optional[Callable[[], str]] = None,
53
+ ) -> LoginStatus:
54
+ """
55
+ Run the full login flow.
56
+
57
+ Parameters
58
+ ----------
59
+ phone : str
60
+ Normalised phone number (e.g. "+989123456789").
61
+ code_callback : callable, optional
62
+ A function that returns the verification code string.
63
+ Defaults to ``input()`` prompt.
64
+
65
+ Returns
66
+ -------
67
+ LoginStatus
68
+ """
69
+ self._driver.get(C.SPLUS_WEB_URL)
70
+ logger.info("Navigated to %s", C.SPLUS_WEB_URL)
71
+ wait_page_load(self._driver, 15)
72
+
73
+ if self.is_logged_in():
74
+ return LoginStatus.ALREADY_LOGGED_IN
75
+
76
+ time.sleep(2)
77
+ self._dismiss_popup()
78
+ time.sleep(1)
79
+
80
+ if not self._enter_phone(phone):
81
+ raise LoginError("Could not enter phone number")
82
+
83
+ if not self._click_next():
84
+ raise LoginError("Could not click 'next' button")
85
+
86
+ time.sleep(3)
87
+
88
+ if code_callback is None:
89
+ code_callback = lambda: input("🔑 Enter verification code: ")
90
+
91
+ code = code_callback()
92
+ if not self._enter_code(code):
93
+ raise LoginError("Could not enter verification code")
94
+
95
+ if not self._wait_for_chat_page():
96
+ raise LoginError("Chat page did not load after login")
97
+
98
+ return LoginStatus.SUCCESS
99
+
100
+ # ── steps ──────────────────────────────────────────
101
+
102
+ def _dismiss_popup(self) -> bool:
103
+ logger.debug("Checking for popup...")
104
+ for xpath in C.XPATH_DISMISS_POPUP_VARIANTS:
105
+ try:
106
+ el = WebDriverWait(self._driver, 5).until(
107
+ EC.element_to_be_clickable((By.XPATH, xpath))
108
+ )
109
+ safe_click(self._driver, el)
110
+ logger.info("Popup dismissed")
111
+ time.sleep(1)
112
+ return True
113
+ except Exception:
114
+ continue
115
+ logger.debug("No popup found")
116
+ return False
117
+
118
+ def _enter_phone(self, phone: str) -> bool:
119
+ logger.info("Entering phone: %s", phone)
120
+ wait_page_load(self._driver)
121
+
122
+ inp = wait_and_find(
123
+ self._driver, By.CSS_SELECTOR, C.SEL_PHONE_INPUT,
124
+ timeout=10, clickable=True,
125
+ )
126
+ if not inp:
127
+ return False
128
+
129
+ inp.click()
130
+ time.sleep(0.2)
131
+ inp.send_keys(Keys.CONTROL + "a")
132
+ time.sleep(0.1)
133
+ inp.send_keys(Keys.DELETE)
134
+ time.sleep(0.2)
135
+
136
+ for ch in phone:
137
+ inp.send_keys(ch)
138
+ time.sleep(0.05)
139
+
140
+ time.sleep(0.5)
141
+ actual = inp.get_attribute("value")
142
+ logger.debug("Phone field value: %s", actual)
143
+ return True
144
+
145
+ def _click_next(self) -> bool:
146
+ logger.info("Clicking 'next'...")
147
+ time.sleep(1)
148
+ btn = wait_and_find(
149
+ self._driver, By.XPATH, C.XPATH_NEXT_BUTTON,
150
+ timeout=10, clickable=True,
151
+ )
152
+ if btn and safe_click(self._driver, btn):
153
+ logger.info("'Next' clicked")
154
+ return True
155
+ return False
156
+
157
+ def _enter_code(self, code: str) -> bool:
158
+ logger.info("Entering verification code")
159
+ wait_page_load(self._driver)
160
+
161
+ code_input = None
162
+ for by, val in [
163
+ (By.ID, C.SEL_CODE_INPUT_ID),
164
+ (By.CSS_SELECTOR, C.SEL_CODE_INPUT_ARIA),
165
+ (By.CSS_SELECTOR, C.SEL_CODE_INPUT_NUMERIC),
166
+ ]:
167
+ code_input = wait_and_find(self._driver, by, val, timeout=10, clickable=True)
168
+ if code_input:
169
+ break
170
+
171
+ if not code_input:
172
+ return False
173
+
174
+ safe_type(self._driver, code_input, code)
175
+ logger.info("Code entered – waiting for automatic redirect")
176
+ return True
177
+
178
+ def _wait_for_chat_page(self) -> bool:
179
+ logger.info("Waiting for chat page...")
180
+ start = time.time()
181
+ while time.time() - start < C.LOGIN_WAIT:
182
+ try:
183
+ found = self._driver.find_elements(
184
+ By.CSS_SELECTOR,
185
+ "[class*='chatlist'], [class*='dialog'], [class*='folders-tabs']",
186
+ )
187
+ if found:
188
+ elapsed = int(time.time() - start)
189
+ logger.info("Chat page loaded (%ds)", elapsed)
190
+ return True
191
+ except Exception:
192
+ pass
193
+ time.sleep(1)
194
+ return True # optimistic – some indicators may not match
soropy/auto_reply.py ADDED
@@ -0,0 +1,158 @@
1
+ """
2
+ Auto-reply engine with rule matching and duplicate prevention.
3
+ """
4
+
5
+ import time
6
+ import threading
7
+ from typing import List, Optional, Dict, Callable
8
+ from dataclasses import dataclass, field
9
+
10
+ from soropy.utils import get_logger
11
+ from soropy.message_tracker import MessageTracker
12
+
13
+ logger = get_logger("soropy.auto_reply")
14
+
15
+
16
+ @dataclass
17
+ class ReplyRule:
18
+ """A single keyword → response mapping."""
19
+ keyword: str
20
+ response: str
21
+ case_sensitive: bool = False
22
+ exact_match: bool = False
23
+ priority: int = 0 # higher = matched first
24
+
25
+ def matches(self, text: str) -> bool:
26
+ if self.case_sensitive:
27
+ src, kw = text.strip(), self.keyword
28
+ else:
29
+ src, kw = text.strip().lower(), self.keyword.lower()
30
+
31
+ if self.exact_match:
32
+ return src == kw
33
+ return kw in src
34
+
35
+
36
+ class AutoReplyEngine:
37
+ """
38
+ Rule-based auto-reply engine with duplicate message tracking.
39
+
40
+ Usage:
41
+ engine = AutoReplyEngine()
42
+ engine.add_rule("سلام", "علیک سلام")
43
+ engine.default_reply = "پیامت دریافت شد"
44
+
45
+ reply = engine.get_reply("سلام دوست من", "علی")
46
+ # → "علیک سلام"
47
+
48
+ # Won't return a reply for the same message twice:
49
+ reply2 = engine.get_reply("سلام دوست من", "علی")
50
+ # → None (already replied)
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ tracker: Optional[MessageTracker] = None,
56
+ default_reply: str = "پیامت دریافت شد",
57
+ default_prefix: str = "جواب",
58
+ ):
59
+ self._rules: List[ReplyRule] = []
60
+ self._tracker = tracker or MessageTracker()
61
+ self.default_reply = default_reply
62
+ self.default_prefix = default_prefix
63
+ self._lock = threading.Lock()
64
+
65
+ # ── Rule management ────────────────────────────────
66
+
67
+ def add_rule(
68
+ self,
69
+ keyword: str,
70
+ response: str,
71
+ case_sensitive: bool = False,
72
+ exact_match: bool = False,
73
+ priority: int = 0,
74
+ ) -> None:
75
+ """Add a reply rule."""
76
+ rule = ReplyRule(
77
+ keyword=keyword,
78
+ response=response,
79
+ case_sensitive=case_sensitive,
80
+ exact_match=exact_match,
81
+ priority=priority,
82
+ )
83
+ with self._lock:
84
+ self._rules.append(rule)
85
+ # Keep sorted by priority descending
86
+ self._rules.sort(key=lambda r: r.priority, reverse=True)
87
+ logger.debug("Rule added: '%s' → '%s'", keyword, response)
88
+
89
+ def remove_rule(self, keyword: str) -> bool:
90
+ """Remove the first rule matching *keyword*."""
91
+ with self._lock:
92
+ for i, rule in enumerate(self._rules):
93
+ if rule.keyword == keyword:
94
+ self._rules.pop(i)
95
+ return True
96
+ return False
97
+
98
+ def clear_rules(self) -> None:
99
+ """Remove all rules."""
100
+ with self._lock:
101
+ self._rules.clear()
102
+
103
+ @property
104
+ def rules(self) -> List[ReplyRule]:
105
+ with self._lock:
106
+ return list(self._rules)
107
+
108
+ def load_rules_from_dict(self, mapping: Dict[str, str]) -> None:
109
+ """Bulk-load keyword→response pairs from a dict."""
110
+ for kw, resp in mapping.items():
111
+ self.add_rule(kw, resp)
112
+
113
+ # ── Reply generation ───────────────────────────────
114
+
115
+ def get_reply(
116
+ self,
117
+ message_text: str,
118
+ chat_name: str,
119
+ msg_index: int = 1,
120
+ skip_duplicate_check: bool = False,
121
+ ) -> Optional[str]:
122
+ """
123
+ Determine the reply for *message_text* from *chat_name*.
124
+
125
+ Returns None if the message has already been replied to
126
+ (unless skip_duplicate_check is True).
127
+ """
128
+ text = message_text.strip()
129
+ if not text:
130
+ return None
131
+
132
+ # Duplicate check
133
+ if not skip_duplicate_check and self._tracker.is_replied(chat_name, text):
134
+ logger.debug("Duplicate detected, skipping: %s / %s", chat_name, text[:30])
135
+ return None
136
+
137
+ # Match rules
138
+ with self._lock:
139
+ for rule in self._rules:
140
+ if rule.matches(text):
141
+ return rule.response
142
+
143
+ # Default
144
+ return f"{self.default_prefix} {msg_index}"
145
+
146
+ def mark_replied(self, chat_name: str, message_text: str) -> None:
147
+ """Record that we replied to this message (prevents duplicates)."""
148
+ self._tracker.mark_replied(chat_name, message_text)
149
+
150
+ # ── Tracker access ─────────────────────────────────
151
+
152
+ @property
153
+ def tracker(self) -> MessageTracker:
154
+ return self._tracker
155
+
156
+ def prune_old_entries(self) -> int:
157
+ """Remove expired tracker entries."""
158
+ return self._tracker.prune()
soropy/browser.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ Browser (Chrome/Chromium) lifecycle management.
3
+
4
+ Handles driver creation with anti-detection, headless mode,
5
+ custom profiles, and teardown.
6
+ """
7
+
8
+ import os
9
+ from typing import Optional
10
+
11
+ from selenium import webdriver
12
+ from selenium.webdriver.chrome.options import Options
13
+ from selenium.webdriver.chrome.service import Service
14
+
15
+ from soropy.session import SessionManager
16
+ from soropy.utils import get_logger
17
+ from soropy.exceptions import BrowserError
18
+
19
+ logger = get_logger("soropy.browser")
20
+
21
+
22
+ class BrowserManager:
23
+ """Creates and manages a Selenium Chrome WebDriver."""
24
+
25
+ def __init__(
26
+ self,
27
+ phone: Optional[str] = None,
28
+ headless: bool = False,
29
+ session_manager: Optional[SessionManager] = None,
30
+ window_size: str = "1300,900",
31
+ extra_args: Optional[list] = None,
32
+ chrome_binary: Optional[str] = None,
33
+ chromedriver_path: Optional[str] = None,
34
+ ):
35
+ self._phone = phone
36
+ self._headless = headless
37
+ self._session_mgr = session_manager or SessionManager()
38
+ self._window_size = window_size
39
+ self._extra_args = extra_args or []
40
+ self._chrome_binary = chrome_binary
41
+ self._chromedriver_path = chromedriver_path
42
+ self._driver: Optional[webdriver.Chrome] = None
43
+
44
+ @property
45
+ def driver(self) -> webdriver.Chrome:
46
+ if self._driver is None:
47
+ raise BrowserError("Browser not started. Call start() first.")
48
+ return self._driver
49
+
50
+ @property
51
+ def is_running(self) -> bool:
52
+ if self._driver is None:
53
+ return False
54
+ try:
55
+ # If the browser window is closed this will throw
56
+ _ = self._driver.title
57
+ return True
58
+ except Exception:
59
+ return False
60
+
61
+ def start(self) -> webdriver.Chrome:
62
+ """Launch Chrome and return the WebDriver."""
63
+ options = Options()
64
+
65
+ # ── Anti-detection ────────────────────────────
66
+ options.add_argument("--no-sandbox")
67
+ options.add_argument("--disable-dev-shm-usage")
68
+ options.add_argument("--disable-blink-features=AutomationControlled")
69
+ options.add_experimental_option("excludeSwitches", ["enable-automation"])
70
+ options.add_experimental_option("useAutomationExtension", False)
71
+ options.add_argument(f"--window-size={self._window_size}")
72
+
73
+ # ── Headless ──────────────────────────────────
74
+ if self._headless:
75
+ options.add_argument("--headless=new")
76
+ options.add_argument("--disable-gpu")
77
+ options.add_argument("--no-first-run")
78
+ options.add_argument("--no-default-browser-check")
79
+ logger.info("Headless mode enabled")
80
+
81
+ # ── Session profile ───────────────────────────
82
+ if self._phone:
83
+ profile_path = self._session_mgr.get_path(self._phone)
84
+ options.add_argument(f"--user-data-dir={profile_path}")
85
+ logger.info("Session profile: %s", profile_path)
86
+
87
+ # ── Custom binary ─────────────────────────────
88
+ if self._chrome_binary:
89
+ options.binary_location = self._chrome_binary
90
+
91
+ # ── Extra args ────────────────────────────────
92
+ for arg in self._extra_args:
93
+ options.add_argument(arg)
94
+
95
+ # ── Service ───────────────────────────────────
96
+ service_kwargs = {}
97
+ if self._chromedriver_path:
98
+ service_kwargs["executable_path"] = self._chromedriver_path
99
+ service = Service(**service_kwargs)
100
+
101
+ try:
102
+ self._driver = webdriver.Chrome(service=service, options=options)
103
+ if not self._headless:
104
+ self._driver.maximize_window()
105
+
106
+ # Remove webdriver flag
107
+ self._driver.execute_script(
108
+ "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
109
+ )
110
+ logger.info("Browser started successfully")
111
+ return self._driver
112
+ except Exception as e:
113
+ raise BrowserError(f"Failed to start Chrome: {e}")
114
+
115
+ def stop(self) -> None:
116
+ """Quit the browser."""
117
+ if self._driver:
118
+ try:
119
+ self._driver.quit()
120
+ logger.info("Browser stopped")
121
+ except Exception:
122
+ pass
123
+ finally:
124
+ self._driver = None
125
+
126
+ def restart(self) -> webdriver.Chrome:
127
+ """Stop and re-start."""
128
+ self.stop()
129
+ return self.start()