Refactor PostParser title generation
This commit is contained in:
@@ -3,4 +3,7 @@
|
||||
- **Location:** Tests are located in the `tests/` directory.
|
||||
- **Execution:**
|
||||
- To run tests for the post parser, use the command: `pytest tests/test_post_parser.py`
|
||||
- To run all tests in the project, use: `pytest tests/`
|
||||
- To run all tests in the project, use: `pytest tests/`
|
||||
|
||||
You should run the tests yourself with the `pytest tests/` command, not ask the user to run them
|
||||
After that, you have to analyze the test results and if they fall down, keep fixing the code or tests depending on the task context
|
||||
+107
-90
@@ -136,37 +136,119 @@ class PostParser:
|
||||
|
||||
# Check for service messages first
|
||||
if service := getattr(message, "service", None):
|
||||
service_str = str(service)
|
||||
if 'PINNED_MESSAGE' in service_str: return "📌 Pinned message"
|
||||
elif 'NEW_CHAT_PHOTO' in service_str: return "🖼 New chat photo"
|
||||
elif 'NEW_CHAT_TITLE' in service_str: return "✏️ New chat title"
|
||||
elif 'VIDEO_CHAT_STARTED' in service_str: return "▶️ Video chat started"
|
||||
elif 'VIDEO_CHAT_ENDED' in service_str: return "⏹ Video chat ended"
|
||||
elif 'VIDEO_CHAT_SCHEDULED' in service_str: return "⏰ Video chat scheduled"
|
||||
elif 'GROUP_CHAT_CREATED' in service_str: return "✨ Group chat created"
|
||||
elif 'CHANNEL_CHAT_CREATED' in service_str: return "✨ Chat created"
|
||||
elif 'DELETE_CHAT_PHOTO' in service_str: return "🗑️ Chat photo deleted"
|
||||
elif 'NEW_CHAT_TITLE' in service_str: return "✏️ New chat title"
|
||||
if 'PINNED_MESSAGE' in str(service): return "📌 Pinned message"
|
||||
elif 'NEW_CHAT_PHOTO' in str(service): return "🖼 New chat photo"
|
||||
elif 'NEW_CHAT_TITLE' in str(service): return "✏️ New chat title"
|
||||
elif 'VIDEO_CHAT_STARTED' in str(service): return "▶️ Video chat started"
|
||||
elif 'VIDEO_CHAT_ENDED' in str(service): return "⏹ Video chat ended"
|
||||
elif 'VIDEO_CHAT_SCHEDULED' in str(service): return "⏰ Video chat scheduled"
|
||||
elif 'GROUP_CHAT_CREATED' in str(service): return "✨ Group chat created"
|
||||
elif 'CHANNEL_CHAT_CREATED' in str(service): return "✨ Chat created"
|
||||
elif 'DELETE_CHAT_PHOTO' in str(service): return "🗑️ Chat photo deleted"
|
||||
|
||||
# Process media content
|
||||
if message.media:
|
||||
# Polls
|
||||
# --- Text Processing --- (Phase 1: Process text if available)
|
||||
text = message.text or message.caption or ''
|
||||
text_stripped = text.strip()
|
||||
processed_title = None
|
||||
text_was_processed = False # Flag to indicate if text processing block was entered
|
||||
min_title_length = 10 # Minimum length for text to be preferred over media/webpage
|
||||
|
||||
if text_stripped:
|
||||
text_was_processed = True
|
||||
text_has_urls = bool(re.search(r'https?://[^\\s<>\"\\\']+', text_stripped))
|
||||
|
||||
clean_text = html.unescape(text)
|
||||
clean_text = re.sub(r'<[^>]+?>', '', clean_text)
|
||||
clean_text = re.sub(r'https?://[^\s<>"\']+', '', clean_text)
|
||||
clean_text = clean_text.strip()
|
||||
|
||||
if clean_text: # If text remains after cleaning
|
||||
# Process URL at the beginning
|
||||
if text_has_urls and "://" in clean_text:
|
||||
clean_text = clean_text.split("://")[0].strip()
|
||||
|
||||
# Process line breaks & punctuation
|
||||
first_line = clean_text.split('\n', 1)[0]
|
||||
first_line = re.sub(r'[.,;:]+$', '', first_line)
|
||||
first_line = first_line.strip()
|
||||
|
||||
# Handle uppercase
|
||||
if first_line.isupper() and len(first_line) > 1:
|
||||
first_line = first_line.lower().capitalize()
|
||||
|
||||
# --- Trim long strings (REVISED LOGIC - Find LAST space before limit) ---
|
||||
cut_at = 37
|
||||
max_extra_chars = 15
|
||||
limit_index = cut_at + max_extra_chars # 52
|
||||
|
||||
if len(first_line) > cut_at:
|
||||
# Define the effective limit for searching and cutting
|
||||
cut_limit = min(len(first_line), limit_index)
|
||||
|
||||
# Find the LAST space before the cut_limit
|
||||
last_space_index = first_line.rfind(' ', 0, cut_limit)
|
||||
|
||||
# Decide the cut index
|
||||
if last_space_index != -1 and last_space_index >= cut_at:
|
||||
# Found a suitable space within [cut_at, cut_limit)
|
||||
cut_index = last_space_index
|
||||
else:
|
||||
# No suitable space found, cut at the hard limit
|
||||
cut_index = cut_limit
|
||||
|
||||
# Slice the string
|
||||
title_segment = first_line[:cut_index]
|
||||
|
||||
# Clean trailing punctuation/spaces from the segment AFTER slicing
|
||||
title_segment = re.sub(r'[\s.,;:]+$', '', title_segment).strip()
|
||||
|
||||
# Only add ellipsis if the title was actually cut
|
||||
# (Check against original first_line length before cleaning segment)
|
||||
if len(first_line[:cut_index]) < len(first_line):
|
||||
processed_title = f"{title_segment}..."
|
||||
else: # Safeguard
|
||||
processed_title = title_segment
|
||||
else:
|
||||
# Length <= cut_at, use as is
|
||||
processed_title = first_line
|
||||
# --- End Trim long strings (REVISED LOGIC) ---
|
||||
|
||||
# --- Decision Logic --- (Phase 2: Decide whether to use processed text or fallback)
|
||||
|
||||
# Condition to use processed_title: It exists AND (it's long enough OR there's no media)
|
||||
# Webpage presence doesn't prevent using short text if there's no media.
|
||||
use_text_title = processed_title and (len(processed_title) >= min_title_length or not message.media)
|
||||
|
||||
if use_text_title:
|
||||
return processed_title
|
||||
|
||||
# --- Fallback Processing --- (Phase 3: If text wasn't suitable or was discarded)
|
||||
|
||||
# Handle specific cases for non-meaningful original text (if text block was entered but didn't yield a usable title)
|
||||
if text_was_processed and not use_text_title:
|
||||
if re.search(r'(?:youtube\.com|youtu\.be)', text_stripped.lower()):
|
||||
return "🎥 YouTube Link"
|
||||
# Check if original text was just a URL and there's a webpage title
|
||||
if message.web_page and message.web_page.title:
|
||||
url_match = re.match(r'^\s*(https?://[^\s<>"\']+)\s*$', text_stripped)
|
||||
if url_match:
|
||||
return f"🔗 {message.web_page.title}"
|
||||
if text_has_urls: # If original text had any URL (and wasn't YouTube/Webpage with title)
|
||||
return "🔗 Web link"
|
||||
# If it only had tags or was short/whitespace, proceed to media/poll check
|
||||
|
||||
# Process media content (if no suitable text title)
|
||||
if message.media:
|
||||
if message.media == MessageMediaType.POLL:
|
||||
if hasattr(message, 'poll') and hasattr(message.poll, 'question'):
|
||||
poll_question = message.poll.question.strip()
|
||||
if poll_question:
|
||||
return f"📊 Poll: {poll_question}"
|
||||
else:
|
||||
return "📊 Poll"
|
||||
|
||||
# PDF documents
|
||||
return "📊 Poll"
|
||||
if message.media == MessageMediaType.DOCUMENT:
|
||||
if hasattr(message.document, 'mime_type') and 'pdf' in message.document.mime_type.lower():
|
||||
return "📄 PDF Document"
|
||||
else:
|
||||
return "📎 Document"
|
||||
|
||||
# Other media types
|
||||
return "📎 Document"
|
||||
if message.media == MessageMediaType.PHOTO: return "📷 Photo"
|
||||
if message.media == MessageMediaType.VIDEO: return "🎥 Video"
|
||||
if message.media == MessageMediaType.ANIMATION: return "🎞 GIF"
|
||||
@@ -175,77 +257,12 @@ class PostParser:
|
||||
if message.media == MessageMediaType.VIDEO_NOTE: return "📱 Video circle"
|
||||
if message.media == MessageMediaType.STICKER: return "🎯 Sticker"
|
||||
|
||||
text = message.text or message.caption or ''
|
||||
text_stripped = text.strip()
|
||||
|
||||
if text_stripped:
|
||||
# Check if there is only HTML or URL
|
||||
text_has_tags = bool(re.search(r'<[^>]+?>', text_stripped))
|
||||
text_has_urls = bool(re.search(r'https?://[^\s<>"\']+', text_stripped))
|
||||
|
||||
# Clean text from tags and links
|
||||
clean_text = html.unescape(text)
|
||||
clean_text = re.sub(r'<[^>]+?>', '', clean_text)
|
||||
clean_text = re.sub(r'https?://[^\s<>"\']+', '', clean_text)
|
||||
clean_text = clean_text.strip()
|
||||
|
||||
# If after cleaning there is no meaningful content
|
||||
if not clean_text:
|
||||
# If there was a <br> or other special tags
|
||||
if text_has_tags and not text_has_urls:
|
||||
return "❓ Unknown Post"
|
||||
|
||||
# If there was a YouTube link
|
||||
if re.search(r'(?:youtube\.com|youtu\.be)', text_stripped.lower()):
|
||||
return "🎥 YouTube Link"
|
||||
|
||||
# Check if we have a web_page with title and text is basically just a URL
|
||||
if message.web_page and message.web_page.title:
|
||||
# Text is basically just a URL
|
||||
url_match = re.match(r'^(https?://[^\s<>"\']+)$', text_stripped)
|
||||
if url_match:
|
||||
return f"🔗 {message.web_page.title}"
|
||||
|
||||
# Otherwise, it's a normal web link
|
||||
return "🔗 Web link"
|
||||
|
||||
# Process URL at the beginning of the title
|
||||
if text_has_urls and "://" in clean_text:
|
||||
# Remove part of text after URL
|
||||
clean_text = clean_text.split("://")[0].strip()
|
||||
|
||||
# Process line breaks - take only the first line
|
||||
first_line = clean_text.split('\n', 1)[0]
|
||||
first_line = re.sub(r'[.,;:]+$', '', first_line) #Remove dot
|
||||
first_line = first_line.strip()
|
||||
|
||||
# Handle uppercase text - convert to title case if the text is all uppercase
|
||||
if first_line.isupper():
|
||||
first_line = first_line.lower().capitalize()
|
||||
|
||||
# Process long strings
|
||||
cut_at = 37
|
||||
max_extra_chars = 15
|
||||
if len(first_line) > cut_at:
|
||||
# Find the next space after cut_at, but not more than 15 chars forward
|
||||
extended_cut = cut_at
|
||||
for i in range(cut_at, min(cut_at + max_extra_chars, len(first_line))):
|
||||
extended_cut = i
|
||||
if first_line[i] == ' ':
|
||||
break
|
||||
title = f"{first_line[:extended_cut]}"
|
||||
title = re.sub(r'[.,;:]+$', '', title)
|
||||
return f"{title}..."
|
||||
|
||||
return first_line
|
||||
|
||||
# Web pages
|
||||
# Web pages (if no text or media title)
|
||||
if message.web_page:
|
||||
if message.web_page.title:
|
||||
return f"🔗 {message.web_page.title}"
|
||||
else:
|
||||
return "🔗 Web link"
|
||||
|
||||
return "🔗 Web link"
|
||||
|
||||
# If nothing matches
|
||||
return "❓ Unknown Post"
|
||||
|
||||
|
||||
+131
-15
@@ -159,17 +159,79 @@ class TestPostParserGenerateTitle(unittest.TestCase):
|
||||
|
||||
def test_generate_title_caption_with_url_and_text(self):
|
||||
message = self._create_mock_message(media=MessageMediaType.PHOTO, caption="Look at this photo! https://example.com/image.jpg")
|
||||
self.assertEqual(self.parser._generate_title(message), "📷 Photo")
|
||||
self.assertEqual(self.parser._generate_title(message), "Look at this photo!")
|
||||
|
||||
def test_generate_title_caption_with_uppercase_text(self):
|
||||
message = self._create_mock_message(text="ЖИЗНЬ НА ОБОЯХ")
|
||||
self.assertEqual(self.parser._generate_title(message), "Жизнь на обоях") #downcase
|
||||
|
||||
def test_generate_title_long_text_trimming(self):
|
||||
long_text = "This is a very long line of text that definitely exceeds the maximum length allowed for a title, so it should be trimmed intelligently at the last space before the limit."
|
||||
message = self._create_mock_message(text=long_text)
|
||||
expected_title = "This is a very long line of text that..." #cut at 37
|
||||
self.assertEqual(self.parser._generate_title(message), expected_title)
|
||||
|
||||
def test_generate_title_long_text_trimming_with_spaces(self):
|
||||
cut_at = 37
|
||||
max_extra = 15
|
||||
limit = cut_at + max_extra # 52
|
||||
|
||||
# --- Test Cases Based on Correct Logic ---
|
||||
|
||||
# 1. Length <= cut_at (37) -> No trim
|
||||
text = "This text is exactly thirty-seven chars" # len 37
|
||||
message = self._create_mock_message(text=text)
|
||||
self.assertEqual(self.parser._generate_title(message), text)
|
||||
|
||||
# 2. Length = 38, no space in check range -> NO Trim (cut index == len)
|
||||
text = "This text is exactly thirty-seven charsX" # len 38
|
||||
message = self._create_mock_message(text=text)
|
||||
self.assertEqual(self.parser._generate_title(message), text)
|
||||
|
||||
# 3. Space found within range [cut_at, limit)
|
||||
# 3a. Space exactly at cut_at (index 37)
|
||||
text = "This text is exactly thirty-seven chars next" # len 42. Space at 37.
|
||||
message = self._create_mock_message(text=text)
|
||||
# Loop range(37, 42) -> i=37. first_line[37]==' '. Break. ext_cut=37. Slice [:37].
|
||||
self.assertEqual(self.parser._generate_title(message), "This text is exactly thirty-seven chars...")
|
||||
|
||||
# 3b.
|
||||
text = "This text is quite a bit longer now space_here_herehere"
|
||||
message = self._create_mock_message(text=text)
|
||||
# Loop range(37, 49). Finds space at i=43. Breaks. ext_cut=43. Slice [:43].
|
||||
self.assertEqual(self.parser._generate_title(message), "This text is quite a bit longer now space_here_hereh...")
|
||||
|
||||
# 3c. Space exactly at limit - 1 (index 51)
|
||||
text = "This is fifty-one characters long with the space1 here X" # len 54. Space at 51.
|
||||
message = self._create_mock_message(text=text)
|
||||
# Loop range(37, 52). Finds space at i=51. Breaks. ext_cut=51. Slice [:51].
|
||||
self.assertEqual(self.parser._generate_title(message), "This is fifty-one characters long with the space1...")
|
||||
|
||||
# 3c. Space exactly at limit - 1 (index 51)
|
||||
text = "This is fifty-one chara the space1 here1 X" # len 54. Space at 51.
|
||||
message = self._create_mock_message(text=text)
|
||||
# Loop range(37, 52). Finds space at i=51. Breaks. ext_cut=51. Slice [:51].
|
||||
self.assertEqual(self.parser._generate_title(message), "This is fifty-one chara the space1 here1...")
|
||||
|
||||
# 4. No space found within range [cut_at, limit)
|
||||
# 4a. Length > cut_at, Length < limit. No space in [cut_at, len). -> Cut at len-1
|
||||
text = "JGHJHKJHKJDHfushdkjfskjdfhnksjdvnskjdnkjsdfjksdhfsdlfijoirukjvnsdkjvskufh" # len 49. cut_at=37. limit=52.
|
||||
message = self._create_mock_message(text=text)
|
||||
# Loop range(37, 49). No space. Finishes. ext_cut=48. Slice [:48].
|
||||
self.assertEqual(self.parser._generate_title(message), "JGHJHKJHKJDHfushdkjfskjdfhnksjdvnskjdnkjsdfjksdhfsdl...")
|
||||
|
||||
text = "JGHJHKJHKJDHfushdkjfskjdfhnksjdvnskjdnkjsdfjksdhfs" # len 49. cut_at=37. limit=52.
|
||||
message = self._create_mock_message(text=text)
|
||||
self.assertEqual(self.parser._generate_title(message), "JGHJHKJHKJDHfushdkjfskjdfhnksjdvnskjdnkjsdfjksdhfs")
|
||||
|
||||
# 4b. Length >= limit. No space in [cut_at, limit). -> Cut at limit-1 = 51
|
||||
text = "ThisIsAnEvenLongerWordWithoutAnySpacesAndDefinitelyMoreThan52Chars" # len 66. cut_at=37. limit=52.
|
||||
message = self._create_mock_message(text=text)
|
||||
# Loop range(37, 52). No space. Finishes. ext_cut=51. Slice [:51].
|
||||
self.assertEqual(self.parser._generate_title(message), "ThisIsAnEvenLongerWordWithoutAnySpacesAndDefinitelyM...")
|
||||
|
||||
# 5. Trailing space/punctuation removal check
|
||||
# 5a. Space found, cut segment ends with space/punct
|
||||
text = "This text is quite a bit longer now, space .,;: here" # len 54. Space at 43.
|
||||
message = self._create_mock_message(text=text)
|
||||
# Loop range(37, 54). Finds space at i=43. Breaks. ext_cut=43. Slice [:43] is "This text is quite a bit longer now, space ".
|
||||
# re.sub removes trailing " .,;: ". Result "This text is quite a bit longer now, space".
|
||||
self.assertEqual(self.parser._generate_title(message), "This text is quite a bit longer now, space...")
|
||||
|
||||
def test_generate_title_break_word_after_limit(self):
|
||||
# Test with a specific text example from the user's query
|
||||
@@ -181,7 +243,8 @@ class TestPostParserGenerateTitle(unittest.TestCase):
|
||||
def test_generate_title_long_text_no_space_trimming(self):
|
||||
long_text = "Thisisaverylonglineoftextthatdefinitelyexceedsthemaximumlengthallowedforatitlesoitshouldbetrimmedatthelimitbecausehasnospaces."
|
||||
message = self._create_mock_message(text=long_text)
|
||||
expected_title = "Thisisaverylonglineoftextthatdefinitelyexceedsthema..." #cut at 30+15 symbols without space
|
||||
# Corrected expected title based on new logic: cut at index 52 (min(52, len)) -> slice[:52]
|
||||
expected_title = "Thisisaverylonglineoftextthatdefinitelyexceedsthemax..."
|
||||
self.assertEqual(self.parser._generate_title(message), expected_title)
|
||||
|
||||
def test_generate_title_text_with_only_html_and_urls(self):
|
||||
@@ -198,11 +261,6 @@ class TestPostParserGenerateTitle(unittest.TestCase):
|
||||
message = self._create_mock_message(media=MessageMediaType.PHOTO, web_page=web_page_mock)
|
||||
self.assertEqual(self.parser._generate_title(message), "📷 Photo") # Media has higher priority
|
||||
|
||||
def test_generate_title_webpage_preview_ignored_with_text(self):
|
||||
web_page_mock = MagicMock()
|
||||
message = self._create_mock_message(text="Some text", web_page=web_page_mock)
|
||||
self.assertEqual(self.parser._generate_title(message), "Some text") # Text has higher priority than fallback web page
|
||||
|
||||
def test_generate_title_fallback_unknown(self):
|
||||
message = self._create_mock_message() # No text, no media, no webpage
|
||||
# Use variable to capture the actual value
|
||||
@@ -219,9 +277,9 @@ class TestPostParserGenerateTitle(unittest.TestCase):
|
||||
self.assertEqual(self.parser._generate_title(message), "📊 Poll: Как правильно?")
|
||||
|
||||
def test_generate_title_webpage_media_type(self):
|
||||
# Webpage media type should be ignored for title generation, text should be used
|
||||
message = self._create_mock_message(media=MessageMediaType.WEB_PAGE, text="Check this out")
|
||||
self.assertEqual(self.parser._generate_title(message), "Check this out")
|
||||
# Webpage media type should be ignored for title generation if there's enough text
|
||||
message = self._create_mock_message(media=MessageMediaType.WEB_PAGE, text="Check this out it is long enough")
|
||||
self.assertEqual(self.parser._generate_title(message), "Check this out it is long enough")
|
||||
|
||||
def test_generate_title_webpage_with_url_text(self):
|
||||
"""Test case that reproduces real issue with VK video link."""
|
||||
@@ -319,5 +377,63 @@ class TestPostParserGenerateTitle(unittest.TestCase):
|
||||
title = self.parser._generate_title(message)
|
||||
self.assertEqual(title, expected_output, f"Ошибка при обработке '{input_text}': получено '{title}', ожидалось '{expected_output}'")
|
||||
|
||||
# --- New tests for text length logic ---
|
||||
|
||||
def test_generate_title_media_with_short_caption(self):
|
||||
"""Media title should be used if caption is short (< 10 chars)."""
|
||||
message = self._create_mock_message(media=MessageMediaType.PHOTO, caption="Hi <3")
|
||||
self.assertEqual(self.parser._generate_title(message), "📷 Photo")
|
||||
|
||||
def test_generate_title_media_with_long_text(self):
|
||||
"""Text title should be used if text is long (>= 10 chars), ignoring media."""
|
||||
message = self._create_mock_message(media=MessageMediaType.VIDEO, text="This is a sufficiently long text.")
|
||||
self.assertEqual(self.parser._generate_title(message), "This is a sufficiently long text")
|
||||
|
||||
def test_generate_title_media_with_long_caption(self):
|
||||
"""Text title from caption should be used if caption is long (>= 10 chars), ignoring media."""
|
||||
message = self._create_mock_message(media=MessageMediaType.PHOTO, caption="This is a sufficiently long caption.")
|
||||
self.assertEqual(self.parser._generate_title(message), "This is a sufficiently long caption")
|
||||
|
||||
def test_generate_title_no_media_with_short_text(self):
|
||||
"""Text title should be used if no media and text is short."""
|
||||
message = self._create_mock_message(text="Short one")
|
||||
self.assertEqual(self.parser._generate_title(message), "Short one")
|
||||
|
||||
def test_generate_title_no_media_with_long_text(self):
|
||||
"""Text title should be used if no media and text is long."""
|
||||
message = self._create_mock_message(text="This is a long text without any media.")
|
||||
self.assertEqual(self.parser._generate_title(message), "This is a long text without any media")
|
||||
|
||||
def test_generate_title_media_with_no_text(self):
|
||||
"""Media title should be used if media exists and text is None."""
|
||||
message = self._create_mock_message(media=MessageMediaType.STICKER, text=None)
|
||||
self.assertEqual(self.parser._generate_title(message), "🎯 Sticker")
|
||||
|
||||
def test_generate_title_media_with_empty_text(self):
|
||||
"""Media title should be used if media exists and text is empty string."""
|
||||
message = self._create_mock_message(media=MessageMediaType.AUDIO, text="")
|
||||
self.assertEqual(self.parser._generate_title(message), "🎵 Audio")
|
||||
|
||||
def test_generate_title_media_with_whitespace_text(self):
|
||||
"""Media title should be used if media exists and text is only whitespace."""
|
||||
message = self._create_mock_message(media=MessageMediaType.VOICE, text=" \\n ")
|
||||
self.assertEqual(self.parser._generate_title(message), "🎤 Voice")
|
||||
|
||||
def test_generate_title_service_message_overrides_long_text(self):
|
||||
"""Service message title should override even long text."""
|
||||
mock_service = MagicMock()
|
||||
mock_service.__str__ = MagicMock(return_value="pyrogram.enums.MessageService.PINNED_MESSAGE")
|
||||
message = self._create_mock_message(service=mock_service, text="This is a long text but should be ignored.")
|
||||
self.assertEqual(self.parser._generate_title(message), "📌 Pinned message")
|
||||
|
||||
def test_generate_title_webpage_with_short_text(self):
|
||||
"""Text title should be used if web_page exists and text is short (but not just URL)."""
|
||||
web_page_mock = MagicMock()
|
||||
web_page_mock.title = "Web Page Title To Ignore"
|
||||
message = self._create_mock_message(web_page=web_page_mock, text="Short txt")
|
||||
self.assertEqual(self.parser._generate_title(message), "Short txt")
|
||||
|
||||
# --- End new tests ---
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user