Commit 286a61de authored by Raphael Beer's avatar Raphael Beer

Refactor/Change: use logging / bool --debug +

  Writing everything to single log file.
  --debug is now a bool flag to raise the
  log level from INFO to DEBUG.
parent fa3e91d8
DEBUG=1
ACCOUNT_FILE=./.htaccounts ACCOUNT_FILE=./.htaccounts
COOKIE_DIR=./.htcookies COOKIE_DIR=./.htcookies
LOG_FILE=./logs/results.log LOG_FILE=./logs/results.log
DEBUG_FILE=./logs/debug.log
PORT=4040 PORT=4040
HOST=localhost HOST=localhost
TWITTER_AUTH_KEY=AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA
CORS_ALLOW=http://localhost:3000
GUEST_SESSIONS=3
MONGO_HOST=localhost MONGO_HOST=localhost
MONGO_PORT=27017 MONGO_PORT=27017
MONGO_DB=shadowban-testing MONGO_DB=shadowban-testing
MONGO_USERNAME=root MONGO_USERNAME=root
MONGO_PASSWORD=5X-rl[(EMdJKll1|qMDU}5xY<t?F.UEo MONGO_PASSWORD=5X-rl[(EMdJKll1|qMDU}5xY<t?F.UEo
TWITTER_AUTH_KEY=AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA
CORS_HOST=http://localhost:3000
GUEST_SESSIONS=3
...@@ -13,8 +13,9 @@ import time ...@@ -13,8 +13,9 @@ import time
from aiohttp import web from aiohttp import web
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from twitter_session import TwitterSession from log import *
from db import connect from db import connect
from twitter_session import TwitterSession
log_file = None log_file = None
debug_file = None debug_file = None
...@@ -22,28 +23,7 @@ db = None ...@@ -22,28 +23,7 @@ db = None
routes = web.RouteTableDef() routes = web.RouteTableDef()
def debug(message): def log_session_info(sessions):
if message.endswith('\n') is False:
message = message + '\n'
if debug_file is not None:
debug_file.write(message)
debug_file.flush()
else:
print(message)
def log(message):
# ensure newline
if message.endswith('\n') is False:
message = message + '\n'
if log_file is not None:
log_file.write(message)
log_file.flush()
else:
print(message)
def print_session_info(sessions):
text = "" text = ""
for session in sessions: for session in sessions:
text += "\n%6d %5d %9d %5d" % (int(session.locked), session.limit, session.remaining, session.reset - int(time.time())) text += "\n%6d %5d %9d %5d" % (int(session.locked), session.limit, session.remaining, session.reset - int(time.time()))
...@@ -52,9 +32,9 @@ def print_session_info(sessions): ...@@ -52,9 +32,9 @@ def print_session_info(sessions):
@routes.get('/.stats') @routes.get('/.stats')
async def stats(request): async def stats(request):
text = "--- GUEST SESSIONS ---\n\nLocked Limit Remaining Reset" text = "--- GUEST SESSIONS ---\n\nLocked Limit Remaining Reset"
text += print_session_info(TwitterSession.guest_sessions) text += log_session_info(TwitterSession.guest_sessions)
text += "\n\n\n--- ACCOUNTS ---\n\nLocked Limit Remaining Reset" text += "\n\n\n--- ACCOUNTS ---\n\nLocked Limit Remaining Reset"
text += print_session_info(TwitterSession.account_sessions) text += log_session_info(TwitterSession.account_sessions)
return web.Response(text=text) return web.Response(text=text)
@routes.get('/.unlocked/{screen_name}') @routes.get('/.unlocked/{screen_name}')
...@@ -73,14 +53,14 @@ async def unlocked(request): ...@@ -73,14 +53,14 @@ async def unlocked(request):
async def api(request): async def api(request):
screen_name = request.match_info['screen_name'] screen_name = request.match_info['screen_name']
if screen_name == "wikileaks" and request.query_string != "watch": if screen_name == "wikileaks" and request.query_string != "watch":
debug("[wikileaks] Returning last watch result") log.debug("[wikileaks] Returning last watch result")
db_result = db.get_result_by_screen_name("wikileaks") db_result = db.get_result_by_screen_name("wikileaks")
return web.json_response(db_result, headers={"Access-Control-Allow-Origin": args.cors_allow}) return web.json_response(db_result, headers={"Access-Control-Allow-Origin": args.cors_allow})
session = TwitterSession.guest_sessions[TwitterSession.test_index % len(TwitterSession.guest_sessions)] session = TwitterSession.guest_sessions[TwitterSession.test_index % len(TwitterSession.guest_sessions)]
TwitterSession.test_index += 1 TwitterSession.test_index += 1
result = await session.test(screen_name) result = await session.test(screen_name)
db.write_result(result) db.write_result(result)
log(json.dumps(result) + '\n') log.debug(json.dumps(result) + '\n')
if (args.cors_allow is not None): if (args.cors_allow is not None):
return web.json_response(result, headers={"Access-Control-Allow-Origin": args.cors_allow}) return web.json_response(result, headers={"Access-Control-Allow-Origin": args.cors_allow})
else: else:
...@@ -101,69 +81,68 @@ async def login_guests(): ...@@ -101,69 +81,68 @@ async def login_guests():
session = TwitterSession() session = TwitterSession()
TwitterSession.guest_sessions.append(session) TwitterSession.guest_sessions.append(session)
await asyncio.gather(*[s.login() for s in TwitterSession.guest_sessions]) await asyncio.gather(*[s.login() for s in TwitterSession.guest_sessions])
log("Guest sessions created") log.info("%d guest sessions created", len(TwitterSession.guest_sessions))
def ensure_dir(path): def ensure_dir(path):
if os.path.isdir(path) is False: if os.path.isdir(path) is False:
print('Creating directory %s' % path) log.info('Creating directory %s' % path)
os.mkdir(path) os.mkdir(path)
parser = argparse.ArgumentParser(description='Twitter Shadowban Tester') parser = argparse.ArgumentParser(description='Twitter Shadowban Tester')
parser.add_argument('--account-file', type=str, default='.htaccounts', help='json file with reference account credentials') parser.add_argument('--account-file', type=str, default='.htaccounts', help='json file with reference account credentials')
parser.add_argument('--cookie-dir', type=str, default=None, help='directory for session account storage') parser.add_argument('--cookie-dir', type=str, default=None, help='directory for session cookies')
parser.add_argument('--log', type=str, default=None, help='log file where test results are written to') parser.add_argument('--log', type=str, default='./logs/backend.log', help='file to write logs to (default: ./logs/backend.log)')
parser.add_argument('--daemon', action='store_true', help='run in background') parser.add_argument('--daemon', action='store_true', help='run in background')
parser.add_argument('--debug', type=str, default=None, help='debug log file') parser.add_argument('--debug', action='store_true', help='show debug log messages')
parser.add_argument('--port', type=int, default=8080, help='port which to listen on') parser.add_argument('--port', type=int, default=8080, help='port which to listen on (default: 8080)')
parser.add_argument('--host', type=str, default='127.0.0.1', help='hostname/ip which to listen on') parser.add_argument('--host', type=str, default='127.0.0.1', help='hostname/ip which to listen on (default:127.0.0.1)')
parser.add_argument('--mongo-host', type=str, default='localhost', help='hostname or IP of mongoDB service to connect to') parser.add_argument('--mongo-host', type=str, default='localhost', help='hostname or IP of mongoDB service to connect to (default: localhost)')
parser.add_argument('--mongo-port', type=int, default=27017, help='port of mongoDB service to connect to') parser.add_argument('--mongo-port', type=int, default=27017, help='port of mongoDB service to connect to (default: 27017)')
parser.add_argument('--mongo-db', type=str, default='tester', help='name of mongo database to use') parser.add_argument('--mongo-db', type=str, default='tester', help='name of mongo database to use (default: tester)')
parser.add_argument('--mongo-username', type=str, default=None, help='user with read/write permissions to --mongo-db') parser.add_argument('--mongo-username', type=str, default=None, help='user with read/write permissions to --mongo-db')
parser.add_argument('--mongo-password', type=str, default=None, help='password for --mongo-username') parser.add_argument('--mongo-password', type=str, default=None, help='password for --mongo-username')
parser.add_argument('--twitter-auth-key', type=str, default=None, help='auth key for twitter guest session', required=True) parser.add_argument('--twitter-auth-key', type=str, default=None, help='auth key for twitter guest session', required=True)
parser.add_argument('--cors-allow', type=str, default=None, help='value for Access-Control-Allow-Origin header') parser.add_argument('--cors-allow', type=str, default=None, help='value for Access-Control-Allow-Origin header')
parser.add_argument('--guest-sessions', type=int, default=10, help='number of Twitter guest sessions to use') parser.add_argument('--guest-sessions', type=int, default=10, help='number of Twitter guest sessions to use (default: 10)')
args = parser.parse_args() args = parser.parse_args()
TwitterSession.twitter_auth_key = args.twitter_auth_key TwitterSession.twitter_auth_key = args.twitter_auth_key
guest_session_pool_size = args.guest_sessions guest_session_pool_size = args.guest_sessions
if (args.cors_allow is None): if (args.cors_allow is None):
debug('[CORS] Running without CORS headers') log.warning('[CORS] Running without CORS headers')
else: else:
debug('[CORS] Allowing requests from: ' + args.cors_allow) log.info('[CORS] Allowing requests from: ' + args.cors_allow)
ensure_dir(args.cookie_dir) ensure_dir(args.cookie_dir)
log_dir = os.path.dirname(args.log)
ensure_dir(log_dir)
add_file_handler(args.log)
try: try:
with open(args.account_file, "r") as f: with open(args.account_file, "r") as f:
accounts = json.loads(f.read()) accounts = json.loads(f.read())
except: except:
accounts = [] accounts = []
if args.log is not None: if args.debug is True:
print("Logging test results to %s" % args.log) set_log_level('debug')
log_dir = os.path.dirname(args.log) else:
ensure_dir(log_dir) set_log_level('info')
log_file = open(args.log, "a")
if args.debug is not None:
print("Logging debug output to %s" % args.debug)
debug_dir = os.path.dirname(args.debug)
ensure_dir(debug_dir)
debug_file = open(args.debug, "a")
async def close_sessions(app): async def close_sessions(app):
print("\nClosing %s guest sessions" % len(TwitterSession.guest_sessions)) log.info("Closing %s guest sessions" % len(TwitterSession.guest_sessions))
for session in TwitterSession.guest_sessions: for session in TwitterSession.guest_sessions:
await session.close() await session.close()
async def close_database(app): async def close_database(app):
global db global db
print("Closing database connection") log.info("Closing database connection")
db.close() db.close()
shutdown_logging()
def run(): def run():
global db global db
db = connect( db = connect(
......
...@@ -8,22 +8,6 @@ if [ "$1" != "" ] && [ -f $1 ]; then ...@@ -8,22 +8,6 @@ if [ "$1" != "" ] && [ -f $1 ]; then
shift shift
fi fi
echo "Starting server..."
echo "--account-file $ACCOUNT_FILE"
echo "--cookie-dir $COOKIE_DIR"
echo "--log $LOG_FILE"
echo "--debug $DEBUG_FILE"
echo "--port "$PORT""
echo "--host "$HOST""
echo "--mongo-host $MONGO_HOST"
echo "--mongo-port $MONGO_PORT"
echo "--mongo-db $MONGO_DB"
echo "--mongo-username $MONGO_USERNAME"
echo "--mongo-password --REDACTED--"
echo "--twitter-auth-key --REDACTED--"
echo "--cors-allow $CORS_HOST"
echo "--guest-sessions $GUEST_SESSIONS"
CMD="python3 -u ./backend.py" CMD="python3 -u ./backend.py"
if [ "$1" == "mprof" ]; then if [ "$1" == "mprof" ]; then
...@@ -32,11 +16,10 @@ if [ "$1" == "mprof" ]; then ...@@ -32,11 +16,10 @@ if [ "$1" == "mprof" ]; then
echo -e "\nRecording memory profile\n" echo -e "\nRecording memory profile\n"
fi fi
$CMD \ SERVICE_ARGS="\
--account-file $ACCOUNT_FILE \ --account-file $ACCOUNT_FILE \
--cookie-dir $COOKIE_DIR \ --cookie-dir $COOKIE_DIR \
--log $LOG_FILE \ --log $LOG_FILE \
--debug $DEBUG_FILE \
--port "$PORT" \ --port "$PORT" \
--host "$HOST" \ --host "$HOST" \
--mongo-host $MONGO_HOST \ --mongo-host $MONGO_HOST \
...@@ -45,5 +28,19 @@ $CMD \ ...@@ -45,5 +28,19 @@ $CMD \
--mongo-username $MONGO_USERNAME \ --mongo-username $MONGO_USERNAME \
--mongo-password $MONGO_PASSWORD \ --mongo-password $MONGO_PASSWORD \
--twitter-auth-key $TWITTER_AUTH_KEY \ --twitter-auth-key $TWITTER_AUTH_KEY \
--cors-allow $CORS_HOST \ --cors-allow $CORS_ALLOW \
--guest-sessions $GUEST_SESSIONS --guest-sessions $GUEST_SESSIONS \
"
if [ -n "$DEBUG" ]; then
SERVICE_ARGS="$SERVICE_ARGS --debug"
fi
CMD="$CMD $SERVICE_ARGS $@"
echo -n "Starting server: "
if [ -n "$DEBUG" ]; then
echo $CMD
else
echo ""
fi
$CMD
...@@ -4,14 +4,16 @@ import sys ...@@ -4,14 +4,16 @@ import sys
from time import sleep from time import sleep
from pymongo import MongoClient, errors as MongoErrors, DESCENDING from pymongo import MongoClient, errors as MongoErrors, DESCENDING
from log import log
class Database: class Database:
def __init__(self, host=None, port=27017, db='tester', username=None, password=None): def __init__(self, host=None, port=27017, db='tester', username=None, password=None):
# collection name definitions # collection name definitions
RESULTS_COLLECTION = 'results' RESULTS_COLLECTION = 'results'
RATELIMIT_COLLECTION = 'rate-limits' RATELIMIT_COLLECTION = 'rate-limits'
print('[mongoDB] Connecting to ' + host + ':' + str(port)) log.info('Connecting to ' + host + ':' + str(port))
print('[mongoDB] Using Database `' + db + '`') log.info('Using Database `' + db + '`')
# client and DB # client and DB
self.client = MongoClient(host, port, serverSelectionTimeoutMS=3, username=username, password=password) self.client = MongoClient(host, port, serverSelectionTimeoutMS=3, username=username, password=password)
self.db = self.client[db] self.db = self.client[db]
...@@ -40,15 +42,13 @@ class Database: ...@@ -40,15 +42,13 @@ class Database:
def connect(host=None, port=27017, db='tester', username=None, password=None): def connect(host=None, port=27017, db='tester', username=None, password=None):
if host is None: if host is None:
raise ValueError('[mongoDB] Database constructor needs a `host`name or ip!') raise ValueError('Database constructor needs a `host`name or ip!')
attempt = 0 attempt = 0
max_attempts = 7 max_attempts = 7
mongo_client = None mongo_client = None
while (mongo_client is None): while (mongo_client is None):
print('[mongoDB|connect] Connecting, ', attempt, '/', max_attempts)
try: try:
mongo_client = Database(host=host, port=port, db=db, username=username, password=password) mongo_client = Database(host=host, port=port, db=db, username=username, password=password)
except Exception as e: except Exception as e:
...@@ -57,5 +57,6 @@ def connect(host=None, port=27017, db='tester', username=None, password=None): ...@@ -57,5 +57,6 @@ def connect(host=None, port=27017, db='tester', username=None, password=None):
sleep(attempt) sleep(attempt)
attempt += 1 attempt += 1
log.warn('Retrying connection, %s/%s', attempt, max_attempts)
return mongo_client return mongo_client
import logging
from logging.handlers import TimedRotatingFileHandler
log_format = '%(asctime)s | %(module)s:%(lineno)d | %(levelname)s: %(message)s'
logging.basicConfig(format=log_format)
log = logging.getLogger(__name__)
def add_file_handler(filename):
handler = TimedRotatingFileHandler(filename=filename, when='midnight')
handler.setFormatter(logging.Formatter(fmt=log_format))
log.addHandler(handler)
log.info('Writing log to %s', filename)
def set_log_level(level):
level_upper = level.upper()
log.setLevel(getattr(logging, level_upper))
log.info('Log level set to %s', level_upper)
def shutdown_logging():
logging.shutdown()
...@@ -2,6 +2,7 @@ import aiohttp ...@@ -2,6 +2,7 @@ import aiohttp
import time import time
import urllib import urllib
from log import log
from statistics import count_sensitives from statistics import count_sensitives
from typeahead import test as test_typeahead from typeahead import test as test_typeahead
...@@ -50,9 +51,9 @@ class TwitterSession: ...@@ -50,9 +51,9 @@ class TwitterSession:
response = await r.json() response = await r.json()
guest_token = response.get("guest_token", None) guest_token = response.get("guest_token", None)
if guest_token is None: if guest_token is None:
debug("Failed to fetch guest token") log.debug("Failed to fetch guest token")
debug(str(response)) log.debug(str(response))
debug(str(self._headers)) log.debug(str(self._headers))
return guest_token return guest_token
def reset_headers(self): def reset_headers(self):
...@@ -68,9 +69,9 @@ class TwitterSession: ...@@ -68,9 +69,9 @@ class TwitterSession:
async def refresh_old_token(self): async def refresh_old_token(self):
if self.username is not None or self.next_refresh is None or time.time() < self.next_refresh: if self.username is not None or self.next_refresh is None or time.time() < self.next_refresh:
return return
debug("Refreshing token: " + str(self._guest_token)) log.debug("Refreshing token: " + str(self._guest_token))
await self.login_guest() await self.login_guest()
debug("New token: " + str(self._guest_token)) log.debug("New token: " + str(self._guest_token))
async def try_close(self): async def try_close(self):
if self._session is not None: if self._session is not None:
...@@ -98,7 +99,7 @@ class TwitterSession: ...@@ -98,7 +99,7 @@ class TwitterSession:
if cookie_dir is not None: if cookie_dir is not None:
cookie_file = os.path.join(cookie_dir, username) cookie_file = os.path.join(cookie_dir, username)
if os.path.isfile(cookie_file): if os.path.isfile(cookie_file):
log("Use cookie file for %s" % username) log.info("Use cookie file for %s" % username)
self._session.cookie_jar.load(cookie_file) self._session.cookie_jar.load(cookie_file)
login_required = False login_required = False
...@@ -116,11 +117,11 @@ class TwitterSession: ...@@ -116,11 +117,11 @@ class TwitterSession:
async with self._session.post('https://twitter.com/sessions', data=form_data, headers=self._headers) as r: async with self._session.post('https://twitter.com/sessions', data=form_data, headers=self._headers) as r:
response = await r.text() response = await r.text()
if str(r.url) == "https://twitter.com/": if str(r.url) == "https://twitter.com/":
log("Login of %s successful" % username) log.info("Login of %s successful" % username)
else: else:
store_cookies = False store_cookies = False
log("Error logging in %s (%s)" % (username, r.url)) log.info("Error logging in %s (%s)" % (username, r.url))
debug("ERROR PAGE\n" + response) log.debug("ERROR PAGE\n" + response)
else: else:
async with self._session.get('https://twitter.com', headers=self._headers) as r: async with self._session.get('https://twitter.com', headers=self._headers) as r:
await r.text() await r.text()
...@@ -143,7 +144,7 @@ class TwitterSession: ...@@ -143,7 +144,7 @@ class TwitterSession:
async with self._session.get(url, headers=self._headers) as r: async with self._session.get(url, headers=self._headers) as r:
result = await r.json() result = await r.json()
except Exception as e: except Exception as e:
debug("EXCEPTION: " + str(type(e))) log.debug("EXCEPTION: " + str(type(e)))
if self.username is None: if self.username is None:
await self.login_guest() await self.login_guest()
raise e raise e
...@@ -220,8 +221,8 @@ class TwitterSession: ...@@ -220,8 +221,8 @@ class TwitterSession:
obj["ban"] = True obj["ban"] = True
return obj return obj
except: except:
debug('Unexpected Exception:') log.debug('Unexpected Exception:')
debug(traceback.format_exc()) log.debug(traceback.format_exc())
return { "error": "EUNKNOWN" } return { "error": "EUNKNOWN" }
async def test_barrier(self, user_id, screen_name): async def test_barrier(self, user_id, screen_name):
...@@ -262,21 +263,21 @@ class TwitterSession: ...@@ -262,21 +263,21 @@ class TwitterSession:
if replied_tweet["reply_count"] > 500: if replied_tweet["reply_count"] > 500:
continue continue
debug('[' + screen_name + '] Barrier Test: ') log.debug('[' + screen_name + '] Barrier Test: ')
debug('[' + screen_name + '] Found:' + tid) log.debug('[' + screen_name + '] Found:' + tid)
debug('[' + screen_name + '] In reply to:' + replied_to_id) log.debug('[' + screen_name + '] In reply to:' + replied_to_id)
reference_session = next_session() reference_session = next_session()
reference_session = self reference_session = self
if reference_session is None: if reference_session is None:
debug('No reference session') log.debug('No reference session')
return return
TwitterSession.account_index += 1 TwitterSession.account_index += 1
before_barrier = await reference_session.tweet_raw(replied_to_id, 1000) before_barrier = await reference_session.tweet_raw(replied_to_id, 1000)
if get_nested(before_barrier, ["globalObjects", "tweets"]) is None: if get_nested(before_barrier, ["globalObjects", "tweets"]) is None:
debug('notweets\n') log.debug('notweets\n')
return return
if tid in self.get_ordered_tweet_ids(before_barrier): if tid in self.get_ordered_tweet_ids(before_barrier):
...@@ -296,7 +297,7 @@ class TwitterSession: ...@@ -296,7 +297,7 @@ class TwitterSession:
after_barrier = await reference_session.tweet_raw(replied_to_id, 1000, cursor=cursor) after_barrier = await reference_session.tweet_raw(replied_to_id, 1000, cursor=cursor)
if get_nested(after_barrier, ["globalObjects", "tweets"]) is None: if get_nested(after_barrier, ["globalObjects", "tweets"]) is None:
debug('retinloop\n') log.debug('retinloop\n')
return return
ids_after_barrier = self.get_ordered_tweet_ids(after_barrier) ids_after_barrier = self.get_ordered_tweet_ids(after_barrier)
if tid in self.get_ordered_tweet_ids(after_barrier): if tid in self.get_ordered_tweet_ids(after_barrier):
...@@ -304,20 +305,20 @@ class TwitterSession: ...@@ -304,20 +305,20 @@ class TwitterSession:
last_result = after_barrier last_result = after_barrier
# happens when replied_to_id tweet has been deleted # happens when replied_to_id tweet has been deleted
debug('[' + screen_name + '] outer loop return') log.debug('[' + screen_name + '] outer loop return')
return { "error": "EUNKNOWN" } return { "error": "EUNKNOWN" }
except: except:
debug('Unexpected Exception in test_barrier:\n') log.debug('Unexpected Exception in test_barrier:\n')
debug(traceback.format_exc()) log.debug(traceback.format_exc())
return { "error": "EUNKNOWN" } return { "error": "EUNKNOWN" }
async def test(self, username): async def test(self, username):
result = {"timestamp": time.time()} result = {"timestamp": time.time()}
profile = {} profile = {}
profile_raw = await self.profile_raw(username) profile_raw = await self.profile_raw(username)
debug('Testing ' + str(username)) log.info('Testing ' + str(username))
if is_another_error(profile_raw, [50, 63]): if is_another_error(profile_raw, [50, 63]):
debug("Other error:" + str(username)) log.debug("Other error:" + str(username))
raise UnexpectedApiError raise UnexpectedApiError
try: try:
...@@ -381,19 +382,13 @@ class TwitterSession: ...@@ -381,19 +382,13 @@ class TwitterSession:
else: else:
result["tests"]["more_replies"] = { "error": "EISGHOSTED"} result["tests"]["more_replies"] = { "error": "EISGHOSTED"}
debug('[' + profile['screen_name'] + '] Writing result to DB') log.debug('[' + profile['screen_name'] + '] Writing result to DB')
return result return result
async def close(self): async def close(self):
await self._session.close() await self._session.close()
def debug(message):
print(message)
def log(message):
print(message)
def next_session(): def next_session():
def key(s): def key(s):
remaining_time = s.reset - time.time() remaining_time = s.reset - time.time()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment