initial commit for a jabberbot based on slixmpp
This commit is contained in:
commit
43ff022969
12 changed files with 1299 additions and 0 deletions
159
plugins/dsa.py
Normal file
159
plugins/dsa.py
Normal file
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# file: dsa.py
|
||||
# date: 26.07.2020
|
||||
# desc: Serves debians security alerts
|
||||
|
||||
|
||||
import logging
|
||||
import urllib3
|
||||
import threading
|
||||
from lxml import etree
|
||||
from manager import Plugin
|
||||
import common
|
||||
|
||||
|
||||
logging = logging.getLogger()
|
||||
|
||||
|
||||
class Plugin(Plugin):
|
||||
|
||||
'''
|
||||
Fetchs debians security alerts, grabs title and links and sends it to
|
||||
the muc.
|
||||
'''
|
||||
|
||||
__module = __name__
|
||||
__command = 'dsa'
|
||||
__description = 'Serves debians security alerts'
|
||||
|
||||
@staticmethod
|
||||
def get_module():
|
||||
return Plugin.__module
|
||||
|
||||
@staticmethod
|
||||
def get_command():
|
||||
return Plugin.__command
|
||||
|
||||
@staticmethod
|
||||
def get_description():
|
||||
return Plugin.__description
|
||||
|
||||
def __init__(self, callback=None):
|
||||
self.callback = callback
|
||||
|
||||
def help(self):
|
||||
return ('!dsa serves the actual debian security alerts. A given '
|
||||
' number reduces the count of alerts displayed to number. '
|
||||
'Not implemented at the moment.'
|
||||
'\nSyntax: !dsa <number>')
|
||||
|
||||
def run(self, stanza):
|
||||
'''
|
||||
Starts a thread to grab debians security alerts returns
|
||||
immediately.
|
||||
param 1: stanza object
|
||||
'''
|
||||
call_msg = 'Call "!help {}"'.format(self.get_command())
|
||||
no_count_msg = ' '.join(('Not a valid count: "{}"!', call_msg))
|
||||
count = False
|
||||
|
||||
muc_nick = common.get_nick_from_stanza(stanza)
|
||||
arguments = common.get_arguments_from_body(stanza)
|
||||
|
||||
if arguments is not False:
|
||||
count = self.get_count(arguments[0])
|
||||
if count is False:
|
||||
self.callback(': '.join((muc_nick, no_count_msg)))
|
||||
return
|
||||
|
||||
logging.debug('Start thread for debian security alerts')
|
||||
dsa_thread = DsaThread(self.callback, count)
|
||||
dsa_thread.run()
|
||||
logging.debug('DSA Thread started')
|
||||
|
||||
def get_count(self, item):
|
||||
'''
|
||||
Try to convert a string into integer.
|
||||
param 1: string
|
||||
retuns: integer or false
|
||||
'''
|
||||
try:
|
||||
value = int(item.strip())
|
||||
return value
|
||||
except Exception as e:
|
||||
logging.warning('Invalid value for count: {}'.format(item))
|
||||
logging.warning('Exception: {}'.format(e))
|
||||
return False
|
||||
|
||||
|
||||
class DsaThread(threading.Thread):
|
||||
'''
|
||||
The thread who fetched and returns the wp search.
|
||||
'''
|
||||
def __init__(self, callback, count):
|
||||
threading.Thread.__init__(self)
|
||||
self.callback = callback
|
||||
self.count = count
|
||||
|
||||
def run(self):
|
||||
'''
|
||||
Starts the thread.
|
||||
'''
|
||||
dsa_response = self.get_file()
|
||||
if dsa_response == False:
|
||||
self.callback('Error while fetching DSA')
|
||||
else:
|
||||
status = dsa_response.status
|
||||
logging.debug("Server returns {}".format(status))
|
||||
if status != 200:
|
||||
self.callback('Server returns {}'.format(status))
|
||||
xmldoc = etree.fromstring(dsa_response.data)
|
||||
message = self.string_factory(xmldoc)
|
||||
self.callback(message)
|
||||
|
||||
def string_factory(self, xmldoc):
|
||||
'''
|
||||
Extracts interested things from the given dsa xml document and
|
||||
creates a string to post im muc.
|
||||
param 1: xml object
|
||||
'''
|
||||
message = 'Debian Security Alerts:'
|
||||
nsmap = {
|
||||
"purl": "http://purl.org/rss/1.0/",
|
||||
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||
}
|
||||
about_list = xmldoc.xpath('//purl:item/@rdf:about', namespaces=nsmap)
|
||||
for about in reversed(about_list):
|
||||
dsa_id = self.get_id_from_about(about)
|
||||
title = xmldoc.xpath(
|
||||
'//purl:item[@rdf:about="{}"]/purl:title/text()'.format(
|
||||
about), namespaces=nsmap)[0]
|
||||
message = '\n'.join((message, title))
|
||||
return message
|
||||
|
||||
def get_file(self):
|
||||
'''
|
||||
Fetchs the security alerts from debian.org
|
||||
param 1: string
|
||||
returns: request object or false
|
||||
'''
|
||||
url = 'https://www.debian.org/security/dsa-long'
|
||||
logging.debug('Try to fetch {}'.format(url))
|
||||
http = urllib3.PoolManager()
|
||||
try:
|
||||
dsa_response = http.request('Get', url)
|
||||
return dsa_response
|
||||
except:
|
||||
logging.debug('{}: failed to fetch'.format(url))
|
||||
return False
|
||||
|
||||
def get_id_from_about(self, about):
|
||||
'''
|
||||
Extracts the dsa id from tehe given string.
|
||||
param 1: string
|
||||
'''
|
||||
return int(about.split('/')[-1].split('-')[1])
|
||||
|
||||
|
131
plugins/status.py
Normal file
131
plugins/status.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# file: ./plugins/status.py
|
||||
# date: 23.07.2020
|
||||
# desc: status plugin ... grabs the krautspaces door status from
|
||||
# https://status.kraut.space/api and returns the result.
|
||||
|
||||
import urllib3
|
||||
import codecs
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
logging = logging.getLogger()
|
||||
from manager import Plugin
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class Plugin(Plugin):
|
||||
|
||||
__module = 'status'
|
||||
__command = 'status'
|
||||
__description = 'Deliver krautspace door status'
|
||||
|
||||
@staticmethod
|
||||
def get_module():
|
||||
return Plugin.__module
|
||||
|
||||
@staticmethod
|
||||
def get_command():
|
||||
return Plugin.__command
|
||||
|
||||
@staticmethod
|
||||
def get_description():
|
||||
return Plugin.__description
|
||||
|
||||
def __init__(self, callback=None):
|
||||
self.callback = callback
|
||||
|
||||
def help(self):
|
||||
return ('!status grabs the doorstatus from https://status.kraut.space '
|
||||
'and delivers him to MUC.\nSyntax: !status')
|
||||
|
||||
def run(self, stanza):
|
||||
'''
|
||||
Starts a thread to grab krautspaces door status und returns
|
||||
immediately.
|
||||
'''
|
||||
logging.debug('Start thread for status')
|
||||
api_thread = ApiThread(self.callback)
|
||||
api_thread.run()
|
||||
logging.debug('Status thread started')
|
||||
|
||||
|
||||
class ApiThread(threading.Thread):
|
||||
'''
|
||||
The thread who fetched, parsed and returns the door status.
|
||||
'''
|
||||
def __init__(self, callback):
|
||||
threading.Thread.__init__(self)
|
||||
self.callback = callback
|
||||
|
||||
def run(self):
|
||||
'''
|
||||
Runs the thread.
|
||||
'''
|
||||
api_page = self.get_file()
|
||||
if api_page == None:
|
||||
self.go_back('Error while connecting API')
|
||||
else:
|
||||
status = api_page.status
|
||||
logging.debug("Page returns {}".format(status))
|
||||
if status == 200:
|
||||
message = self.parse_api(api_page.data)
|
||||
self.go_back(message)
|
||||
else:
|
||||
self.go_back('Error while fetching API')
|
||||
|
||||
def get_file(self):
|
||||
'''
|
||||
Grabs the API file, parse it and returns a json ... otherwise none.
|
||||
returns: json or none
|
||||
'''
|
||||
url = 'https://status.kraut.space/api'
|
||||
logging.debug('Try to fetch {}'.format(url))
|
||||
http = urllib3.PoolManager()
|
||||
try:
|
||||
api_page = http.request('Get', url)
|
||||
logging.debug('{}: successfull fetched'.format(url))
|
||||
except:
|
||||
logging.debug('{}: failed to fetch'.format(url))
|
||||
return None
|
||||
return api_page
|
||||
|
||||
def parse_api(self, page_data):
|
||||
'''
|
||||
Extracts needed data from given json and create the message.
|
||||
param 1: json
|
||||
returns: string
|
||||
'''
|
||||
timestamp = None
|
||||
status = None
|
||||
message = None
|
||||
json_string = page_data.decode('utf-8')
|
||||
json_dict = json.loads(json_string)
|
||||
status = json_dict['state']['open']
|
||||
unixtime = json_dict['state']['lastchange']
|
||||
timestamp = time.strftime('%d.%m.%Y %H:%M', time.localtime(unixtime))
|
||||
logging.debug('Open: {}; Time: {}; Last Change: {}'.format(
|
||||
status, timestamp, unixtime))
|
||||
if status is True:
|
||||
message = 'Space is open since {}.'.format(timestamp)
|
||||
elif status is False:
|
||||
message = 'Space is closed since {}.'.format(timestamp)
|
||||
else:
|
||||
message = 'Invalid status: "{}"'.format(status)
|
||||
return message
|
||||
|
||||
def go_back(self, message):
|
||||
'''
|
||||
param 1: string
|
||||
'''
|
||||
lock.acquire()
|
||||
try:
|
||||
self.callback(message)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
151
plugins/timer.py
Normal file
151
plugins/timer.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# file: timer.py
|
||||
# date: 25.07.2020
|
||||
# desc: Starts a time for max. 24 hours and sends a message after it has
|
||||
# expied.
|
||||
|
||||
import common
|
||||
import logging
|
||||
logging = logging.getLogger()
|
||||
from manager import Plugin
|
||||
from threading import Timer, Lock
|
||||
|
||||
lock = Lock()
|
||||
|
||||
|
||||
class Plugin(Plugin):
|
||||
|
||||
'''
|
||||
Timer starts a timer and sends a message to muc after it has expired.
|
||||
'''
|
||||
|
||||
__module = 'timer'
|
||||
__command = 'timer'
|
||||
__description = 'Starts a timer.'
|
||||
|
||||
@staticmethod
|
||||
def get_module():
|
||||
return Plugin.__module
|
||||
|
||||
@staticmethod
|
||||
def get_command():
|
||||
return Plugin.__command
|
||||
|
||||
@staticmethod
|
||||
def get_description():
|
||||
return Plugin.__description
|
||||
|
||||
def __init__(self, callback=None):
|
||||
self.callback = callback
|
||||
|
||||
def help(self):
|
||||
return ('!timer sets a timer that sends a completion message when it '
|
||||
'has expired. The function expects a time value and '
|
||||
'optionally a unit. Possible units are seconds, seconde, '
|
||||
'sekunden, sec, sek, s, minute, minutes, minuten, min, '
|
||||
'hour, hours, stunde, stunden or h. If no unit is given it '
|
||||
'uses seconds as default. The timer runs for a maximum of 24 '
|
||||
'hours.'
|
||||
'\nSyntax: !timer <value> <unit>')
|
||||
|
||||
def run(self, stanza):
|
||||
'''
|
||||
'''
|
||||
self.units = ('seconde', 'seconds', 'sekunde', 'sekunden',
|
||||
'sec', 'sek', 's',
|
||||
'minute', 'minutes', 'minuten', 'min', 'm',
|
||||
'hour', 'hours', 'h', 'stunde', 'stunden')
|
||||
|
||||
call_msg = 'Call "!help {}"'.format(self.get_command())
|
||||
no_args_msg = ' '.join(('Timer without time!', call_msg))
|
||||
no_valu_msg = ' '.join(('Not a valid value: "{}"!', call_msg))
|
||||
no_unit_msg = ' '.join(('Not a valid unit: "{}"!', call_msg))
|
||||
to_long_msg = 'Sorry ... but i dont want stay here for this time'
|
||||
|
||||
value = None
|
||||
unit = None
|
||||
|
||||
muc_nick = common.get_nick_from_stanza(stanza)
|
||||
arguments = common.get_arguments_from_body(stanza)
|
||||
logging.debug('Arguments: {}'.format(arguments))
|
||||
|
||||
if arguments is False:
|
||||
logging.warning('No arguments for timer. Abort.')
|
||||
self.callback(': '.join((muc_nick, no_args_msg)))
|
||||
return
|
||||
else:
|
||||
try:
|
||||
value = self.get_timer_value(arguments[0])
|
||||
unit = self.get_timer_unit(arguments[1])
|
||||
except IndexError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.warning('Error while creating timer')
|
||||
logging.warning('Exception: {}'.format(e))
|
||||
if value in (None, False):
|
||||
msg = ': '.join((muc_nick, no_valu_msg.format(arguments[0])))
|
||||
self.callback(msg)
|
||||
return
|
||||
if unit is False:
|
||||
msg = ': '.join((muc_nick, no_unit_msg.format(arguments[1])))
|
||||
self.callback(msg)
|
||||
return
|
||||
# timer starten
|
||||
elif unit is None:
|
||||
self.start_timer(value, self.callback, muc_nick)
|
||||
else:
|
||||
value = value * unit
|
||||
if value > 5184000:
|
||||
logging.warning('Timer value to hight: {}'.format(value))
|
||||
self.callback(': '.join((muc_nick, to_long_msg)))
|
||||
self.start_timer(value, self.callback, muc_nick)
|
||||
|
||||
def get_timer_value(self, item):
|
||||
'''
|
||||
Try to convert a string into integer.
|
||||
param 1: string
|
||||
retuns: integer or false
|
||||
'''
|
||||
try:
|
||||
value = int(item.strip())
|
||||
return value
|
||||
except Exception as e:
|
||||
logging.warning('Invalid value for timer: {}'.format(item))
|
||||
logging.warning('Exception: {}'.format(e))
|
||||
return False
|
||||
|
||||
def get_timer_unit(self, item):
|
||||
'''
|
||||
param 1: string
|
||||
returns: integer
|
||||
'''
|
||||
if item.strip() in self.units[0:7]:
|
||||
logging.debug('Timer unit: seconds')
|
||||
factor = 1
|
||||
elif item.strip() in self.units[7:12]:
|
||||
logging.debug('Timer unit: minutes')
|
||||
factor = 60
|
||||
elif item.strip() in self.units[13:17]:
|
||||
logging.debug('Timer unit: hours')
|
||||
factor = 60 * 60
|
||||
else:
|
||||
logging.warning('Invalid unit for timer: {}'.format(item.strip()))
|
||||
factor = False
|
||||
return factor
|
||||
|
||||
def start_timer(self, value, callback, muc_nick):
|
||||
'''
|
||||
Starts the timer. Arguments are the duration in seconds and the
|
||||
chatters nick who called timer.
|
||||
param 1: integer
|
||||
param 2: string
|
||||
'''
|
||||
timer_start_msg = 'Timer started'
|
||||
timer_ends_msg = '{}: Timer finished!'.format(muc_nick)
|
||||
t = Timer(value, callback, [timer_ends_msg])
|
||||
t.start()
|
||||
logging.debug('Timer started for {} seconds'.format(value))
|
||||
self.callback(': '.join((muc_nick, timer_start_msg)))
|
||||
|
84
plugins/uptime.py
Normal file
84
plugins/uptime.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# file: uptime.py
|
||||
# date: 25.07.2020
|
||||
# desc: Returns bots uptime
|
||||
|
||||
|
||||
import time
|
||||
import datetime
|
||||
import logging
|
||||
logging = logging.getLogger()
|
||||
from manager import Plugin
|
||||
from constants import Const
|
||||
|
||||
|
||||
class Plugin(Plugin):
|
||||
|
||||
'''
|
||||
Returns the hackbots uptime. The variable birth from the globalvar
|
||||
module is used to become the starttime.
|
||||
'''
|
||||
|
||||
__module = __name__
|
||||
__command = 'uptime'
|
||||
__description = 'Returns hackbots uptime'
|
||||
|
||||
@staticmethod
|
||||
def get_module():
|
||||
return Plugin.__module
|
||||
|
||||
@staticmethod
|
||||
def get_command():
|
||||
return Plugin.__command
|
||||
|
||||
@staticmethod
|
||||
def get_description():
|
||||
return Plugin.__description
|
||||
|
||||
def __init__(self, callback=None):
|
||||
self.callback = callback
|
||||
|
||||
def help(self):
|
||||
return ('!uptime returns hackbots uptime. There are no options. '
|
||||
'\nSyntax: !uptime')
|
||||
|
||||
def run(self, stanza):
|
||||
'''
|
||||
Starts the plugin.
|
||||
'''
|
||||
const = Const()
|
||||
birth = const.birth
|
||||
uptime = self.calculate_uptime(birth)
|
||||
if uptime == False:
|
||||
logging.warning('Error while calculating uptime')
|
||||
self.callback('Error while calculating uptime')
|
||||
else:
|
||||
self.callback('My uptime is {}'.format(uptime))
|
||||
|
||||
|
||||
|
||||
def calculate_uptime(self, birth):
|
||||
logging.debug('Calculate uptime since {} UTC'.format(birth))
|
||||
start = self.birth2time(birth)
|
||||
now = datetime.datetime.utcnow()
|
||||
print('*** Start: {} :: Now: {}'.format(start, now))
|
||||
try:
|
||||
uptime = now - start
|
||||
print('Uptime: {}'.format(uptime))
|
||||
return uptime
|
||||
except Exception as exc:
|
||||
logging.warning('ERROR: {}'.format(exc))
|
||||
return False
|
||||
|
||||
|
||||
def birth2time(self, birth):
|
||||
time_object = None
|
||||
datum, zeit = birth.split('T')
|
||||
jahr, monat, tag = datum.split('-')
|
||||
stunde, minute, sekunde = zeit.split(':')
|
||||
time_object = datetime.datetime(int(jahr), int(monat), int(tag), \
|
||||
int(stunde), int(minute), int(sekunde), 0, None)
|
||||
return time_object
|
||||
|
155
plugins/wpsearch.py
Normal file
155
plugins/wpsearch.py
Normal file
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# file: generic.plgin
|
||||
# date: 23.07.2020
|
||||
# desc: generic plugin
|
||||
|
||||
|
||||
import json
|
||||
import common
|
||||
import logging
|
||||
import requests
|
||||
import threading
|
||||
from manager import Plugin
|
||||
logging = logging.getLogger()
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class Plugin(Plugin):
|
||||
|
||||
'''
|
||||
'''
|
||||
|
||||
__module = __name__
|
||||
__command = 'wpsearch'
|
||||
__description = 'Seach wikipedia'
|
||||
|
||||
@staticmethod
|
||||
def get_module():
|
||||
return Plugin.__module
|
||||
|
||||
@staticmethod
|
||||
def get_command():
|
||||
return Plugin.__command
|
||||
|
||||
@staticmethod
|
||||
def get_description():
|
||||
return Plugin.__description
|
||||
|
||||
def __init__(self, callback=None):
|
||||
self.callback = callback
|
||||
|
||||
def help(self):
|
||||
return ('!wp-search searches in wikipedia for a given search term. '
|
||||
'Arguments are language shortcut and search pattern. If no '
|
||||
'language shortcut is given, en is used as default. '
|
||||
'\nSyntax: !wp-search <lang> <pattern>')
|
||||
|
||||
def run(self, stanza):
|
||||
'''
|
||||
'''
|
||||
lang = 'en'
|
||||
pattern = None
|
||||
call_msg = 'Call "!help {}"'.format(self.get_command())
|
||||
no_args_msg = ' '.join(('No search pattern!', call_msg))
|
||||
|
||||
muc_nick = common.get_nick_from_stanza(stanza)
|
||||
arguments = common.get_arguments_from_body(stanza)
|
||||
logging.debug('Arguments: {}'.format(arguments))
|
||||
|
||||
if arguments is False:
|
||||
logging.warning('No arguments for wp search. Abort.')
|
||||
self.callback(': '.join((muc_nick, no_args_msg)))
|
||||
return
|
||||
else:
|
||||
if len(arguments) == 1:
|
||||
pattern = arguments[0]
|
||||
elif len(arguments) > 1:
|
||||
logging.warning('Not implemented yet.')
|
||||
self.callback('Not implemented yet')
|
||||
return
|
||||
logging.debug('Start thread for wp search')
|
||||
self.callback('Search started')
|
||||
api_thread = ApiThread(self.callback)
|
||||
api_thread.run(lang, pattern, muc_nick)
|
||||
|
||||
|
||||
class ApiThread(threading.Thread):
|
||||
'''
|
||||
The thread who fetched and returns the wp search.
|
||||
'''
|
||||
def __init__(self, callback):
|
||||
threading.Thread.__init__(self)
|
||||
self.callback = callback
|
||||
|
||||
def run(self, lang, pattern, muc_nick):
|
||||
'''
|
||||
Starts the thread.
|
||||
'''
|
||||
data = self.get_file(lang, pattern)
|
||||
if data == False:
|
||||
self.callback('Error while connecting WP')
|
||||
else:
|
||||
# TODO: check if its a json needed !
|
||||
logging.debug(data)
|
||||
msg = self.string_factory(data)
|
||||
self.callback(': '.join((muc_nick, msg)))
|
||||
|
||||
def get_file(self, lang, pattern):
|
||||
'''
|
||||
Grabs the API file, parse it and returns a json ... otherwise none.
|
||||
param 1: string
|
||||
param 2: string
|
||||
param 3: string
|
||||
returns: json or none
|
||||
'''
|
||||
api_url = 'https://{}.wikipedia.org/w/api.php'.format(lang)
|
||||
api_params = {
|
||||
'action': 'query',
|
||||
'prop': 'extracts|info',
|
||||
'explaintext': '',
|
||||
'redirects': '',
|
||||
'exchars': 200,
|
||||
'continue': '',
|
||||
'format': 'json',
|
||||
'titles': pattern,
|
||||
'inprop': 'url',
|
||||
'formatversion': 2
|
||||
}
|
||||
|
||||
try:
|
||||
logging.debug('Try to fetch {}'.format(api_url))
|
||||
session = requests.Session()
|
||||
response = session.get(url = api_url, params = api_params)
|
||||
data = response.json()
|
||||
logging.debug('{}: successfull fetched'.format(api_url))
|
||||
except:
|
||||
logging.debug('{}: failed to fetch'.format(api_url))
|
||||
return False
|
||||
return data
|
||||
|
||||
def string_factory(self, data):
|
||||
'''
|
||||
param 1: json
|
||||
returns: string
|
||||
'''
|
||||
msg = ''
|
||||
if 'redirects' in data['query'].keys():
|
||||
for i in data['query']['redirects']:
|
||||
rfr = i['from']
|
||||
rto = i['to']
|
||||
msg = '\n'.join((msg, 'Redirect from {} to {}'.format( \
|
||||
rfr, rto)))
|
||||
logging.debug('Message: {}'.format(msg))
|
||||
pages = data['query']['pages']
|
||||
for i in pages:
|
||||
if 'extract' in i.keys():
|
||||
msg = '\n'.join((msg, 'Summary:', i['extract']))
|
||||
logging.debug('Message: {}'.format(msg))
|
||||
else: msg = '\n'.join((msg, 'Nothing found'))
|
||||
msg = '\n'.join((msg, 'URL:', i['fullurl']))
|
||||
logging.debug('Message: {}'.format(msg))
|
||||
return msg
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue