WIP: recaptcha
This commit is contained in:
@@ -1138,8 +1138,8 @@ def show_video_info(*args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def show_recaptcha(key, referer):
|
def show_recaptcha(key, referer):
|
||||||
from platformcode.recaptcha import Recaptcha
|
from platformcode.recaptcha import Kodi
|
||||||
return Recaptcha("Recaptcha.xml", config.get_runtime_path()).Start(key, referer)
|
return Kodi(key, referer).run()
|
||||||
|
|
||||||
|
|
||||||
def alert_no_disponible_server(server):
|
def alert_no_disponible_server(server):
|
||||||
|
|||||||
+138
-51
@@ -1,77 +1,164 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
from builtins import range
|
|
||||||
import xbmcgui
|
import xbmcgui
|
||||||
from core import httptools
|
from core import httptools
|
||||||
from core import scrapertools
|
from core import filetools
|
||||||
from platformcode import config
|
from platformcode import config, platformtools
|
||||||
from platformcode import platformtools
|
from platformcode import logger
|
||||||
|
from lib.librecaptcha.recaptcha import ReCaptcha, Solver, DynamicSolver, MultiCaptchaSolver, Solution, \
|
||||||
|
ImageGridChallenge
|
||||||
|
|
||||||
lang = 'it'
|
lang = 'it'
|
||||||
|
tiles_pos = (75+390, 90+40)
|
||||||
|
grid_width = 450
|
||||||
|
tiles_texture_focus = 'white.png'
|
||||||
|
tiles_texture_checked = 'Controls/check_mark.png'
|
||||||
|
|
||||||
class Recaptcha(xbmcgui.WindowXMLDialog):
|
|
||||||
def Start(self, key, referer):
|
|
||||||
self.referer = referer
|
|
||||||
self.key = key
|
|
||||||
self.headers = {'Referer': self.referer}
|
|
||||||
|
|
||||||
api_js = httptools.downloadpage("https://www.google.com/recaptcha/api.js?hl=" + lang).data
|
class Kodi:
|
||||||
version = scrapertools.find_single_match(api_js, 'po.src\s*=\s*\'(.*?)\';').split("/")[5]
|
def __init__(self, key, referer):
|
||||||
self.url = "https://www.google.com/recaptcha/api/fallback?k=" + self.key + "&hl=" + lang + "&v=" + version + "&t=2&ff=true"
|
self.rc = ReCaptcha(
|
||||||
self.doModal()
|
api_key=key,
|
||||||
# Reload
|
site_url=referer,
|
||||||
if self.result == {}:
|
user_agent=httptools.get_user_agent(),
|
||||||
self.result = Recaptcha("Recaptcha.xml", config.get_runtime_path()).Start(self.key, self.referer)
|
)
|
||||||
|
|
||||||
return self.result
|
def run(self) -> str:
|
||||||
|
result = self.rc.first_solver()
|
||||||
|
while not isinstance(result, str) and result is not False:
|
||||||
|
solution = self.run_solver(result)
|
||||||
|
if solution:
|
||||||
|
result = self.rc.send_solution(solution)
|
||||||
|
logger.debug(result)
|
||||||
|
platformtools.dialog_notification("Captcha corretto", "Verifica conclusa")
|
||||||
|
return result
|
||||||
|
|
||||||
def update_window(self):
|
def run_solver(self, solver: Solver) -> Solution:
|
||||||
data = httptools.downloadpage(self.url, headers=self.headers).data
|
a = {
|
||||||
self.message = scrapertools.find_single_match(data,
|
DynamicSolver: DynamicKodi,
|
||||||
'<div class="rc-imageselect-desc[a-z-]*">(.*?)(?:</label>|</div>)').replace(
|
MultiCaptchaSolver: MultiCaptchaKodi,
|
||||||
"<strong>", "[B]").replace("</strong>", "[/B]")
|
}
|
||||||
self.token = scrapertools.find_single_match(data, 'name="c" value="([^"]+)"')
|
b = a[type(solver)]
|
||||||
self.image = "https://www.google.com/recaptcha/api2/payload?k=%s&c=%s" % (self.key, self.token)
|
c = b("Recaptcha.xml", config.get_runtime_path())
|
||||||
self.result = {}
|
c.solver = solver
|
||||||
self.getControl(10020).setImage(self.image)
|
return c.run()
|
||||||
self.getControl(10000).setText(self.message)
|
|
||||||
self.setFocusId(10005)
|
|
||||||
|
|
||||||
|
|
||||||
|
class SolverKodi(xbmcgui.WindowXMLDialog):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.mensaje = kwargs.get("mensaje")
|
self.goal = ""
|
||||||
self.imagen = kwargs.get("imagen")
|
self.closed = False
|
||||||
|
self.result = None
|
||||||
|
self.image_path = ''
|
||||||
|
self.indices = {}
|
||||||
|
logger.debug()
|
||||||
|
|
||||||
|
def show_image(self, image, goal):
|
||||||
|
self.image_path = config.get_temp_file(str(random.randint(1, 1000)) + '.png')
|
||||||
|
filetools.write(self.image_path, image)
|
||||||
|
self.goal = goal.replace('<strong>', '[B]').replace('</strong>', '[/B]')
|
||||||
|
self.doModal()
|
||||||
|
|
||||||
def onInit(self):
|
def onInit(self):
|
||||||
#### Kodi 18 compatibility ####
|
logger.debug(self.image_path)
|
||||||
if config.get_platform(True)['num_version'] < 18:
|
self.getControl(10020).setImage(self.image_path, False)
|
||||||
self.setCoordinateResolution(2)
|
self.getControl(10000).setText(self.goal)
|
||||||
self.update_window()
|
self.setFocusId(10005)
|
||||||
|
for x in range(self.num_columns):
|
||||||
|
for y in range(self.num_rows):
|
||||||
|
self.addControl(xbmcgui.ControlRadioButton(int(tiles_pos[0] + x*grid_width/self.num_rows), int(tiles_pos[1] + y*grid_width/self.num_columns),
|
||||||
|
int(grid_width/self.num_rows), int(grid_width/self.num_columns), '', tiles_texture_focus, tiles_texture_focus,
|
||||||
|
focusTexture=tiles_texture_checked, noFocusTexture=tiles_texture_checked))
|
||||||
|
|
||||||
|
|
||||||
|
class MultiCaptchaKodi(SolverKodi):
|
||||||
|
"""
|
||||||
|
multicaptcha challenges present you with one large image split into a grid of tiles and ask you to select the tiles that contain a given object.
|
||||||
|
Occasionally, the image will not contain the object, but rather something that looks similar.
|
||||||
|
It is possible to select no tiles in this case, but reCAPTCHA may have been fooled by the similar-looking object and would reject a selection of no tiles.
|
||||||
|
"""
|
||||||
|
def run(self) -> Solution:
|
||||||
|
result = self.solver.first_challenge()
|
||||||
|
while not isinstance(result, Solution):
|
||||||
|
if not isinstance(result, ImageGridChallenge):
|
||||||
|
raise TypeError("Unexpected type: {}".format(type(result)))
|
||||||
|
indices = self.handle_challenge(result)
|
||||||
|
result = self.solver.select_indices(indices)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def handle_challenge(self, challenge: ImageGridChallenge):
|
||||||
|
goal = challenge.goal.plain
|
||||||
|
self.num_rows = challenge.dimensions.rows
|
||||||
|
self.num_columns = challenge.dimensions.columns
|
||||||
|
|
||||||
|
num_tiles = challenge.dimensions.count
|
||||||
|
image = challenge.image
|
||||||
|
self.show_image(image, goal)
|
||||||
|
if self.closed:
|
||||||
|
return False
|
||||||
|
return self.result
|
||||||
|
|
||||||
def onClick(self, control):
|
def onClick(self, control):
|
||||||
if control == 10003:
|
if control == 10003:
|
||||||
self.result = None
|
self.closed = True
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
elif control == 10004:
|
elif control == 10004:
|
||||||
self.result = {}
|
self.result = None
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
elif control == 10002:
|
elif control == 10002:
|
||||||
self.result = [int(k) for k in range(9) if self.result.get(k, False)]
|
self.result = [int(k) for k in range(9) if self.indices.get(k, False)]
|
||||||
post = {
|
self.close()
|
||||||
"c": self.token,
|
else:
|
||||||
"response": self.result
|
index = control - 10005
|
||||||
}
|
self.indices[control - 10005] = not self.indices.get(index, False)
|
||||||
|
|
||||||
data = httptools.downloadpage(self.url, post=post, headers=self.headers).data
|
|
||||||
from platformcode import logger
|
class DynamicKodi(SolverKodi):
|
||||||
logger.debug(data)
|
"""
|
||||||
self.result = scrapertools.find_single_match(data, '<div class="fbc-verification-token">.*?>([^<]+)<')
|
dynamic challenges present you with a grid of different images and ask you to select the images that match the given description.
|
||||||
if self.result:
|
Each time you click an image, a new one takes its place. Usually, three images from the initial set match the description,
|
||||||
platformtools.dialog_notification("Captcha corretto", "Verifica conclusa")
|
and at least one of the replacement images does as well.
|
||||||
|
"""
|
||||||
|
def run(self):
|
||||||
|
challenge = self.solver.get_challenge()
|
||||||
|
image = challenge.image
|
||||||
|
goal = challenge.goal.raw
|
||||||
|
self.num_rows = challenge.dimensions.rows
|
||||||
|
self.num_columns = challenge.dimensions.columns
|
||||||
|
num_tiles = challenge.dimensions.count
|
||||||
|
|
||||||
|
self.show_image(image, goal)
|
||||||
|
if self.closed:
|
||||||
|
return False
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
def changeTile(self, path, index, delay):
|
||||||
|
from core.support import dbg
|
||||||
|
dbg()
|
||||||
|
time.sleep(delay)
|
||||||
|
tile = self.getControl(10005 + index)
|
||||||
|
self.addControl(xbmcgui.ControlImage(tile.getX(), tile.getY(), tile.getWidth(), tile.getHeigh(), path))
|
||||||
|
|
||||||
|
def onClick(self, control):
|
||||||
|
if control == 10003:
|
||||||
|
self.closed = True
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
elif control == 10004:
|
||||||
|
self.result = None
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
elif control == 10002:
|
||||||
|
self.result = self.solver.finish()
|
||||||
self.close()
|
self.close()
|
||||||
else:
|
else:
|
||||||
self.result = {}
|
index = control - 10005
|
||||||
self.close()
|
tile = self.solver.select_tile(index)
|
||||||
else:
|
path = config.get_temp_file(str(random.randint(1, 1000)) + '.png')
|
||||||
self.result[control - 10005] = not self.result.get(control - 10005, False)
|
filetools.write(path, tile.image)
|
||||||
|
Thread(target=self.changeTile, args=(path, index, tile.delay)).start()
|
||||||
|
|||||||
@@ -92,132 +92,6 @@
|
|||||||
<width>450</width>
|
<width>450</width>
|
||||||
<height>450</height>
|
<height>450</height>
|
||||||
</control>
|
</control>
|
||||||
<control type="togglebutton" id="10005">
|
|
||||||
<top>90</top>
|
|
||||||
<left>75</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10002</onup>
|
|
||||||
<ondown>10008</ondown>
|
|
||||||
<onleft>10007</onleft>
|
|
||||||
<onright>10006</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10006">
|
|
||||||
<top>90</top>
|
|
||||||
<left>225</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10003</onup>
|
|
||||||
<ondown>10009</ondown>
|
|
||||||
<onleft>10005</onleft>
|
|
||||||
<onright>10007</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10007">
|
|
||||||
<top>90</top>
|
|
||||||
<left>375</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10004</onup>
|
|
||||||
<ondown>10010</ondown>
|
|
||||||
<onleft>10006</onleft>
|
|
||||||
<onright>10005</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10008">
|
|
||||||
<top>240</top>
|
|
||||||
<left>75</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10005</onup>
|
|
||||||
<ondown>10011</ondown>
|
|
||||||
<onleft>10010</onleft>
|
|
||||||
<onright>10009</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10009">
|
|
||||||
<top>240</top>
|
|
||||||
<left>225</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10006</onup>
|
|
||||||
<ondown>10012</ondown>
|
|
||||||
<onleft>10008</onleft>
|
|
||||||
<onright>10010</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10010">
|
|
||||||
<top>240</top>
|
|
||||||
<left>375</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10007</onup>
|
|
||||||
<ondown>10013</ondown>
|
|
||||||
<onleft>10009</onleft>
|
|
||||||
<onright>10008</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10011">
|
|
||||||
<top>390</top>
|
|
||||||
<left>75</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10008</onup>
|
|
||||||
<ondown>10002</ondown>
|
|
||||||
<onleft>10013</onleft>
|
|
||||||
<onright>10012</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10012">
|
|
||||||
<top>390</top>
|
|
||||||
<left>225</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10009</onup>
|
|
||||||
<ondown>10003</ondown>
|
|
||||||
<onleft>10011</onleft>
|
|
||||||
<onright>10013</onright>
|
|
||||||
</control>
|
|
||||||
<control type="togglebutton" id="10013">
|
|
||||||
<top>390</top>
|
|
||||||
<left>375</left>
|
|
||||||
<width>150</width>
|
|
||||||
<height>150</height>
|
|
||||||
<texturefocus colordiffuse="AA232323">white.png</texturefocus>
|
|
||||||
<texturenofocus colordiffuse="00232323">white.png</texturenofocus>
|
|
||||||
<alttexturefocus colordiffuse="FF0082C2">Controls/check_mark.png</alttexturefocus>
|
|
||||||
<alttexturenofocus colordiffuse="FFFFFFFF">Controls/check_mark.png</alttexturenofocus>
|
|
||||||
<onup>10010</onup>
|
|
||||||
<ondown>10004</ondown>
|
|
||||||
<onleft>10012</onleft>
|
|
||||||
<onright>10011</onright>
|
|
||||||
</control>
|
|
||||||
</control>
|
</control>
|
||||||
</controls>
|
</controls>
|
||||||
</window>
|
</window>
|
||||||
@@ -4,12 +4,8 @@
|
|||||||
"ignore_urls": [],
|
"ignore_urls": [],
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"pattern": "(https?://maxstream.video/uprot/[a-zA-Z0-9]+)",
|
"pattern": "(https?://maxstream.video/(?:[^/]+/)?([a-zA-Z0-9]+))",
|
||||||
"url": "\\1"
|
"url": "\\1"
|
||||||
},
|
|
||||||
{
|
|
||||||
"pattern": "https?://maxstream.video/(?:e/|embed-|cast/)?([a-z0-9]+)",
|
|
||||||
"url": "http://maxstream.video/\\1"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-18
@@ -9,22 +9,15 @@ import requests
|
|||||||
from core import httptools, scrapertools, support
|
from core import httptools, scrapertools, support
|
||||||
from lib import jsunpack
|
from lib import jsunpack
|
||||||
from platformcode import logger, config, platformtools
|
from platformcode import logger, config, platformtools
|
||||||
if sys.version_info[0] >= 3:
|
|
||||||
import urllib.parse as urlparse
|
|
||||||
else:
|
|
||||||
import urlparse
|
|
||||||
|
|
||||||
|
|
||||||
def test_video_exists(page_url):
|
def test_video_exists(page_url):
|
||||||
logger.debug("(page_url='%s')" % page_url)
|
logger.debug("(page_url='%s')" % page_url)
|
||||||
|
|
||||||
global data
|
global data, new_url
|
||||||
if 'uprot/' in page_url:
|
new_url = httptools.downloadpage(page_url, follow_redirects=False, cloudscraper=True).headers.get('location', page_url)
|
||||||
id = httptools.downloadpage(page_url, follow_redirects=False, cloudscraper=True).headers.get('location').split('/')[-1]
|
# page_url = requests.head('http://lozioangie.altervista.org/max_anticaptcha.php?id=' + id).headers.get('location')
|
||||||
else:
|
data = httptools.downloadpage(new_url, cloudscraper=True).data
|
||||||
id = page_url.split('/')[-1]
|
|
||||||
page_url = requests.head('http://lozioangie.altervista.org/max_anticaptcha.php?id=' + id).headers.get('location')
|
|
||||||
data = httptools.downloadpage(page_url, cloudscraper=True).data
|
|
||||||
|
|
||||||
if scrapertools.find_single_match(data, '(?<!none);[^>]*>file was deleted'):
|
if scrapertools.find_single_match(data, '(?<!none);[^>]*>file was deleted'):
|
||||||
return False, config.get_localized_string(70449) % "MaxStream"
|
return False, config.get_localized_string(70449) % "MaxStream"
|
||||||
@@ -35,14 +28,14 @@ def test_video_exists(page_url):
|
|||||||
def get_video_url(page_url, premium=False, user="", password="", video_password=""):
|
def get_video_url(page_url, premium=False, user="", password="", video_password=""):
|
||||||
logger.debug("url=" + page_url)
|
logger.debug("url=" + page_url)
|
||||||
video_urls = []
|
video_urls = []
|
||||||
global data
|
global data, new_url
|
||||||
if 'captcha' in data:
|
# if 'captcha' in data:
|
||||||
httptools.set_cookies(requests.get('http://lozioangie.altervista.org/maxcookie.php').json())
|
# httptools.set_cookies(requests.get('http://lozioangie.altervista.org/maxcookie.php').json())
|
||||||
data = httptools.downloadpage(page_url, cloudscraper=True).data
|
# data = httptools.downloadpage(page_url, cloudscraper=True).data
|
||||||
|
|
||||||
# sitekey = scrapertools.find_multiple_matches(data, """data-sitekey=['"] *([^"']+)""")
|
sitekey = scrapertools.find_multiple_matches(data, """data-sitekey=['"] *([^"']+)""")
|
||||||
# if sitekey: sitekey = sitekey[-1]
|
if sitekey: sitekey = sitekey[-1]
|
||||||
# captcha = platformtools.show_recaptcha(sitekey, page_url) if sitekey else ''
|
captcha = platformtools.show_recaptcha(sitekey, new_url) if sitekey else ''
|
||||||
#
|
#
|
||||||
# possibleParam = scrapertools.find_multiple_matches(data,
|
# possibleParam = scrapertools.find_multiple_matches(data,
|
||||||
# r"""<input.*?(?:name=["']([^'"]+).*?value=["']([^'"]*)['"]>|>)""")
|
# r"""<input.*?(?:name=["']([^'"]+).*?value=["']([^'"]*)['"]>|>)""")
|
||||||
|
|||||||
@@ -657,6 +657,7 @@ class SearchWindow(xbmcgui.WindowXML):
|
|||||||
else: item.contentSerieName = self.RESULTS.getSelectedItem().getLabel()
|
else: item.contentSerieName = self.RESULTS.getSelectedItem().getLabel()
|
||||||
item.folder = False
|
item.folder = False
|
||||||
|
|
||||||
|
logger.debug(item)
|
||||||
Search(item, self.thActions)
|
Search(item, self.thActions)
|
||||||
if close_action:
|
if close_action:
|
||||||
self.close()
|
self.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user