diff --git a/LibWinDog/Config.py b/LibWinDog/Config.py index 5483fda..739e46e 100755 --- a/LibWinDog/Config.py +++ b/LibWinDog/Config.py @@ -10,24 +10,24 @@ MastodonUrl = '' MastodonToken = '' TelegramId = 1637713483 -TelegramToken = '' -TelegramAdmins = [ 123456789, ] -TelegramWhitelist = [ 123456789, ] +TelegramToken = "0123456789:abcdefghijklmnopqrstuvwxyz123456789" +TelegramAdmins = [ 123456789, 634314973, ] +TelegramWhitelist = [ 123456789, 634314973, ] TelegramRestrict = False -AdminIds = [ "123456789@telegram", "admin@activitypub@mastodon.example.com", ] +AdminIds = [ "123456789@telegram", "634314973@telegram", "admin@activitypub@mastodon.example.com", ] -DefaultLang = 'en' +DefaultLang = "en" Debug = False Dumper = False -CmdPrefixes = '.!/' +CmdPrefixes = ".!/" # False: ASCII output; True: ANSI Output (must be escaped) -ExecAllowed = {'date': False, 'fortune': False, 'neofetch': True, 'uptime': False} -WebUserAgent = f'WinDog v.Staging' +ExecAllowed = {"date": False, "fortune": False, "neofetch": True, "uptime": False} +WebUserAgent = "WinDog v.Staging" -Endpoints = { +# TODO deprecate this in favour of new module API +Endpoints = (Endpoints | { "start": cStart, - "help": cHelp, #"config": cConfig, "source": cSource, "ping": cPing, @@ -43,7 +43,6 @@ Endpoints = { "floor": multifun, "hands": multifun, "sessocto": multifun, - "hash": cHash, #"encode": cEncode, #"decode": cDecode, #"time": cTime, @@ -51,9 +50,4 @@ Endpoints = { "exec": cExec, #"format": cFormat, #"frame": cFrame, - "embedded": cEmbedded, - "web": cWeb, - "translate": cTranslate, - "unsplash": cUnsplash, - "safebooru": cSafebooru, -} +}) diff --git a/LibWinDog/Platforms/Mastodon.py b/LibWinDog/Platforms/Mastodon/Mastodon.py similarity index 74% rename from LibWinDog/Platforms/Mastodon.py rename to LibWinDog/Platforms/Mastodon/Mastodon.py index 591d29b..9324722 100644 --- a/LibWinDog/Platforms/Mastodon.py +++ b/LibWinDog/Platforms/Mastodon/Mastodon.py @@ -1,4 +1,5 @@ import mastodon +from bs4 import BeautifulSoup def MastodonSender(event, manager, Data, Destination, TextPlain, TextMarkdown) -> None: if InDict(Data, 'Media'): @@ -13,7 +14,6 @@ def MastodonSender(event, manager, Data, Destination, TextPlain, TextMarkdown) - visibility=('direct' if event['status']['visibility'] == 'direct' else 'unlisted'), ) -# TODO make this non-blocking or else we can't load it dynamically def MastodonMain() -> None: if not (MastodonUrl and MastodonToken): return @@ -25,10 +25,12 @@ def MastodonMain() -> None: if not Msg.split('@')[0]: Msg = ' '.join('@'.join(Msg.split('@')[1:]).strip().split(' ')[1:]).strip() if Msg[0] in CmdPrefixes: - Cmd = ParseCmd(Msg) - Cmd.messageId = event['status']['id'] - if Cmd.Name in Endpoints: - Endpoints[Cmd.Name]({"Event": event, "Manager": Mastodon}, Cmd) - Mastodon.stream_user(MastodonListener()) + cmd = ParseCmd(Msg) + if cmd: + cmd.messageId = event['status']['id'] + if cmd.Name in Endpoints: + Endpoints[cmd.Name]({"Event": event, "Manager": Mastodon}, cmd) + Mastodon.stream_user(MastodonListener(), run_async=True) + +RegisterPlatform(name="Mastodon", main=MastodonMain, sender=MastodonSender, managerClass=mastodon.Mastodon) -Platforms["Mastodon"] = {"main": MastodonMain, "sender": MastodonSender, "managerClass": mastodon.Mastodon} diff --git a/LibWinDog/Platforms/Mastodon/requirements.txt b/LibWinDog/Platforms/Mastodon/requirements.txt new file mode 100644 index 0000000..c018747 --- /dev/null +++ b/LibWinDog/Platforms/Mastodon/requirements.txt @@ -0,0 +1,2 @@ +Mastodon.py +beautifulsoup4 diff --git a/LibWinDog/Platforms/Matrix.py b/LibWinDog/Platforms/Matrix.py index f7d8753..a58688f 100644 --- a/LibWinDog/Platforms/Matrix.py +++ b/LibWinDog/Platforms/Matrix.py @@ -1,4 +1,8 @@ def MatrixMain() -> None: pass -#Platforms["Matrix"] = {"main": MatrixMain} +def MatrixSender() -> None: + pass + +#RegisterPlatform(name="Matrix", main=MatrixMain, sender=MatrixSender) + diff --git a/LibWinDog/Platforms/Telegram.py b/LibWinDog/Platforms/Telegram/Telegram.py similarity index 59% rename from LibWinDog/Platforms/Telegram.py rename to LibWinDog/Platforms/Telegram/Telegram.py index d6268b7..b3706c4 100644 --- a/LibWinDog/Platforms/Telegram.py +++ b/LibWinDog/Platforms/Telegram/Telegram.py @@ -21,31 +21,32 @@ def TelegramHandleCmd(update:telegram.Update): def TelegramQueryHandle(update:telegram.Update, context:CallbackContext=None) -> None: if not (update and update.message): return - Cmd = ParseCmd(update.message.text) - Cmd.messageId = update.message.message_id - Cmd.TextPlain = Cmd.Body - Cmd.TextMarkdown = update.message.text_markdown_v2 - Cmd.Text = GetWeightedText((Cmd.TextMarkdown, Cmd.TextPlain)) - if Cmd and Cmd.Tokens[0][0] in CmdPrefixes and Cmd.Name in Endpoints: - Cmd.User = { - "Name": update.message.from_user.first_name, - "Tag": update.message.from_user.username, - "Id": f'{update.message.from_user.id}@telegram', - } - if update.message.reply_to_message: - Cmd.Quoted = SimpleNamespace(**{ - "messageId": update.message.reply_to_message.message_id, - "Body": update.message.reply_to_message.text, - "TextPlain": update.message.reply_to_message.text, - "TextMarkdown": update.message.reply_to_message.text_markdown_v2, - "Text": GetWeightedText((update.message.reply_to_message.text_markdown_v2, update.message.reply_to_message.text)), - "User": { - "Name": update.message.reply_to_message.from_user.first_name, - "Tag": update.message.reply_to_message.from_user.username, - "Id": f'{update.message.reply_to_message.from_user.id}@telegram', - }, + cmd = ParseCmd(update.message.text) + if cmd: + cmd.messageId = update.message.message_id + cmd.TextPlain = cmd.Body + cmd.TextMarkdown = update.message.text_markdown_v2 + cmd.Text = GetWeightedText((cmd.TextMarkdown, cmd.TextPlain)) + if cmd.Tokens[0][0] in CmdPrefixes and cmd.Name in Endpoints: + cmd.User = SimpleNamespace(**{ + "Name": update.message.from_user.first_name, + "Tag": update.message.from_user.username, + "Id": f'{update.message.from_user.id}@telegram', }) - Endpoints[Cmd.Name]({"Event": update, "Manager": context}, Cmd) + if update.message.reply_to_message: + cmd.Quoted = SimpleNamespace(**{ + "messageId": update.message.reply_to_message.message_id, + "Body": update.message.reply_to_message.text, + "TextPlain": update.message.reply_to_message.text, + "TextMarkdown": update.message.reply_to_message.text_markdown_v2, + "Text": GetWeightedText((update.message.reply_to_message.text_markdown_v2, update.message.reply_to_message.text)), + "User": SimpleNamespace(**{ + "Name": update.message.reply_to_message.from_user.first_name, + "Tag": update.message.reply_to_message.from_user.username, + "Id": f'{update.message.reply_to_message.from_user.id}@telegram', + }), + }) + Endpoints[cmd.Name]({"Event": update, "Manager": context}, cmd) if Debug and Dumper: Text = update.message.text Text = (Text.replace('\n', '\\n') if Text else '') @@ -74,8 +75,8 @@ def TelegramMain() -> None: return updater = telegram.ext.Updater(TelegramToken) dispatcher = updater.dispatcher - #dispatcher.add_handler(CommandHandler('config', cConfig)) dispatcher.add_handler(MessageHandler(Filters.text | Filters.command, TelegramQueryHandle)) updater.start_polling() -Platforms["Telegram"] = {"main": TelegramMain, "sender": TelegramSender, "eventClass": telegram.Update} +RegisterPlatform(name="Telegram", main=TelegramMain, sender=TelegramSender, eventClass=telegram.Update) + diff --git a/LibWinDog/Platforms/Telegram/requirements.txt b/LibWinDog/Platforms/Telegram/requirements.txt new file mode 100644 index 0000000..659eb2e --- /dev/null +++ b/LibWinDog/Platforms/Telegram/requirements.txt @@ -0,0 +1 @@ +python-telegram-bot==13.4.1 diff --git a/LibWinDog/Platforms/Web.py b/LibWinDog/Platforms/Web.py new file mode 100644 index 0000000..838f8b4 --- /dev/null +++ b/LibWinDog/Platforms/Web.py @@ -0,0 +1,8 @@ +def WebMain() -> None: + pass + +def WebSender() -> None: + pass + +#RegisterPlatform(name="Web", main=WebMain, sender=WebSender) + diff --git a/ModWinDog/Codings.py b/ModWinDog/Codings.py index e69de29..23ea6ad 100644 --- a/ModWinDog/Codings.py +++ b/ModWinDog/Codings.py @@ -0,0 +1 @@ +import base64 diff --git a/ModWinDog/Hash.py b/ModWinDog/Hash.py deleted file mode 100644 index 9054d86..0000000 --- a/ModWinDog/Hash.py +++ /dev/null @@ -1,15 +0,0 @@ -import hashlib - -# Module: Hash -# Responds with the hash-sum of a message received. -def cHash(Context, Data) -> None: - if len(Data.Tokens) >= 3 and Data.Tokens[1] in hashlib.algorithms_available: - Alg = Data.Tokens[1] - Hash = hashlib.new(Alg, Alg.join(Data.Body.split(Alg)[1:]).strip().encode()).hexdigest() - SendMsg(Context, { - "TextPlain": Hash, - "TextMarkdown": MarkdownCode(Hash, True), - }) - else: - SendMsg(Context, {"Text": choice(Locale.__('hash.usage')).format(Data.Tokens[0], hashlib.algorithms_available)}) - diff --git a/ModWinDog/Hashing.py b/ModWinDog/Hashing.py new file mode 100644 index 0000000..c13626e --- /dev/null +++ b/ModWinDog/Hashing.py @@ -0,0 +1,17 @@ +import hashlib + +def cHash(context, data) -> None: + if len(data.Tokens) >= 3 and data.Tokens[1] in hashlib.algorithms_available: + Alg = data.Tokens[1] + Hash = hashlib.new(Alg, Alg.join(data.Body.split(Alg)[1:]).strip().encode()).hexdigest() + SendMsg(context, { + "TextPlain": Hash, + "TextMarkdown": MarkdownCode(Hash, True), + }) + else: + SendMsg(context, {"Text": choice(Locale.__('hash.usage')).format(data.Tokens[0], hashlib.algorithms_available)}) + +RegisterModule(name="Hashing", group="Geek", summary="Functions for hashing of textual content.", endpoints={ + "Hash": CreateEndpoint(["hash"], summary="Responds with the hash-sum of a message received.", handler=cHash), +}) + diff --git a/ModWinDog/Help.py b/ModWinDog/Help.py new file mode 100644 index 0000000..b8de617 --- /dev/null +++ b/ModWinDog/Help.py @@ -0,0 +1,19 @@ +# TODO: implement /help feature + +def cHelp(context, data=None) -> None: + moduleList, commands = '', '' + for module in Modules: + summary = Modules[module]["summary"] + endpoints = Modules[module]["endpoints"] + moduleList += (f"\n\n{module}" + (f": {summary}" if summary else '')) + for endpoint in endpoints: + summary = endpoints[endpoint]["summary"] + moduleList += (f"\n* /{', /'.join(endpoints[endpoint]['names'])}" + (f": {summary}" if summary else '')) + for cmd in Endpoints.keys(): + commands += f'* /{cmd}\n' + SendMsg(context, {"Text": f"[ Available Modules ]{moduleList}\n\nFull Endpoints List:\n{commands}"}) + +RegisterModule(name="Help", group="Basic", endpoints={ + "Help": CreateEndpoint(["help"], summary="Provides help for the bot. For now, it just lists the commands.", handler=cHelp), +}) + diff --git a/ModWinDog/Internet.py b/ModWinDog/Internet/Internet.py similarity index 59% rename from ModWinDog/Internet.py rename to ModWinDog/Internet/Internet.py index a485766..94303c9 100644 --- a/ModWinDog/Internet.py +++ b/ModWinDog/Internet/Internet.py @@ -2,18 +2,16 @@ from urlextract import URLExtract from urllib import parse as UrlParse from urllib.request import urlopen, Request -def HttpGet(Url:str): - return urlopen(Request(Url, headers={"User-Agent": WebUserAgent})) +def HttpGet(url:str): + return urlopen(Request(url, headers={"User-Agent": WebUserAgent})) -# Module: Embedded -# Rewrite a link trying to make sure we have an embed view. -def cEmbedded(Context, Data) -> None: - if len(Data.Tokens) >= 2: +def cEmbedded(context, data) -> None: + if len(data.Tokens) >= 2: # Find links in command body - Text = (Data.TextMarkdown + ' ' + Data.TextPlain) - elif Data.Quoted and Data.Quoted.Text: + Text = (data.TextMarkdown + ' ' + data.TextPlain) + elif data.Quoted and data.Quoted.Text: # Find links in quoted message - Text = (Data.Quoted.TextMarkdown + ' ' + Data.Quoted.TextPlain) + Text = (data.Quoted.TextMarkdown + ' ' + data.Quoted.TextPlain) else: # TODO Error message return @@ -39,17 +37,15 @@ def cEmbedded(Context, Data) -> None: elif urlDomain == "vm.tiktok.com": urlDomain = "vm.vxtiktok.com" url = urlDomain + url[len(urlDomain):] - SendMsg(Context, {"TextPlain": f"{{{proto}{url}}}"}) + SendMsg(context, {"TextPlain": f"{{{proto}{url}}}"}) # else TODO error message? -# Module: Web -# Provides results of a DuckDuckGo search. -def cWeb(Context, Data) -> None: - if Data.Body: +def cWeb(context, data) -> None: + if data.Body: try: - QueryUrl = UrlParse.quote(Data.Body) + QueryUrl = UrlParse.quote(data.Body) Req = HttpGet(f'https://html.duckduckgo.com/html?q={QueryUrl}') - Caption = f'🦆🔎 "{Data.Body}": https://duckduckgo.com/?q={QueryUrl}\n\n' + Caption = f'🦆🔎 "{data.Body}": https://duckduckgo.com/?q={QueryUrl}\n\n' Index = 0 for Line in Req.read().decode().replace('\t', ' ').splitlines(): if ' class="result__a" ' in Line and ' href="//duckduckgo.com/l/?uddg=' in Line: @@ -61,32 +57,28 @@ def cWeb(Context, Data) -> None: Caption += f'[{Index}] {Title} : {{{Link}}}\n\n' else: continue - SendMsg(Context, {"TextPlain": f'{Caption}...'}) + SendMsg(context, {"TextPlain": f'{Caption}...'}) except Exception: raise else: pass -# Module: Translate -# Return the received message after translating it in another language. -def cTranslate(Context, Data) -> None: - if len(Data.Tokens) < 3: +def cTranslate(context, data) -> None: + if len(data.Tokens) < 3: return try: - Lang = Data.Tokens[1] + Lang = data.Tokens[1] # TODO: Use many different public Lingva instances in rotation to avoid overloading a specific one - Result = json.loads(HttpGet(f'https://lingva.ml/api/v1/auto/{Lang}/{UrlParse.quote(Lang.join(Data.Body.split(Lang)[1:]))}').read())["translation"] - SendMsg(Context, {"TextPlain": Result}) + Result = json.loads(HttpGet(f'https://lingva.ml/api/v1/auto/{Lang}/{UrlParse.quote(Lang.join(data.Body.split(Lang)[1:]))}').read())["translation"] + SendMsg(context, {"TextPlain": Result}) except Exception: raise -# Module: Unsplash -# Send a picture sourced from Unsplash. -def cUnsplash(Context, Data) -> None: +def cUnsplash(context, data) -> None: try: - Req = HttpGet(f'https://source.unsplash.com/random/?{UrlParse.quote(Data.Body)}') + Req = HttpGet(f'https://source.unsplash.com/random/?{UrlParse.quote(data.Body)}') ImgUrl = Req.geturl().split('?')[0] - SendMsg(Context, { + SendMsg(context, { "TextPlain": f'{{{ImgUrl}}}', "TextMarkdown": MarkdownCode(ImgUrl, True), "Media": Req.read(), @@ -94,18 +86,18 @@ def cUnsplash(Context, Data) -> None: except Exception: raise -# Module: Safebooru -# Send a picture sourced from Safebooru. -def cSafebooru(Context, Data) -> None: +def cSafebooru(context, data) -> None: ApiUrl = 'https://safebooru.org/index.php?page=dapi&s=post&q=index&limit=100&tags=' try: - if Data.Body: - for i in range(7): - ImgUrls = HttpGet(f'{ApiUrl}md5:{RandHexStr(3)}%20{UrlParse.quote(Data.Body)}').read().decode().split(' file_url="')[1:] + if data.Body: + for i in range(7): # retry a bunch of times if we can't find a really random result + ImgUrls = HttpGet(f'{ApiUrl}md5:{RandHexStr(3)}%20{UrlParse.quote(data.Body)}').read().decode().split(' file_url="')[1:] if ImgUrls: break + if not ImgUrls: # literal search + ImgUrls = HttpGet(f'{ApiUrl}{UrlParse.quote(data.Body)}').read().decode().split(' file_url="')[1:] if not ImgUrls: - ImgUrls = HttpGet(f'{ApiUrl}{UrlParse.quote(Data.Body)}').read().decode().split(' file_url="')[1:] + return SendMsg(context, {"Text": "Error: Could not get any result from Safebooru."}) ImgXml = choice(ImgUrls) ImgUrl = ImgXml.split('"')[0] ImgId = ImgXml.split(' id="')[1].split('"')[0] @@ -117,9 +109,9 @@ def cSafebooru(Context, Data) -> None: ImgId = ImgUrl.split('?')[-1] break if ImgUrl: - SendMsg(Context, { + SendMsg(context, { "TextPlain": f'[{ImgId}]\n{{{ImgUrl}}}', - "TextMarkdown": f'\\[`{ImgId}`\\]\n{MarkdownCode(ImgUrl, True)}', + "TextMarkdown": (f'\\[`{ImgId}`\\]\n' + MarkdownCode(ImgUrl, True)), "Media": HttpGet(ImgUrl).read(), }) else: @@ -127,3 +119,11 @@ def cSafebooru(Context, Data) -> None: except Exception: raise +RegisterModule(name="Internet", group="Internet", summary="Tools and toys related to the Internet.", endpoints={ + "Embedded": CreateEndpoint(["embedded"], summary="Rewrites a link, trying to bypass embed view protection.", handler=cEmbedded), + "Web": CreateEndpoint(["web"], summary="Provides results of a DuckDuckGo search.", handler=cWeb), + "Translate": CreateEndpoint(["translate"], summary="Returns the received message after translating it in another language.", handler=cTranslate), + "Unsplash": CreateEndpoint(["unsplash"], summary="Sends a picture sourced from Unsplash.", handler=cUnsplash), + "Safebooru": CreateEndpoint(["safebooru"], summary="Sends a picture sourced from Safebooru.", handler=cSafebooru), +}) + diff --git a/ModWinDog/Internet/requirements.txt b/ModWinDog/Internet/requirements.txt new file mode 100644 index 0000000..6ef9041 --- /dev/null +++ b/ModWinDog/Internet/requirements.txt @@ -0,0 +1,2 @@ +urllib3 +urlextract diff --git a/ModWinDog/Mods.py b/ModWinDog/Mods.py index 46905bb..d024ae2 100755 --- a/ModWinDog/Mods.py +++ b/ModWinDog/Mods.py @@ -5,51 +5,43 @@ # Module: Percenter # Provides fun trough percentage-based toys. -def percenter(Context, Data) -> None: - SendMsg(Context, {"Text": choice(Locale.__(f'{Data.Name}.{"done" if Data.Body else "empty"}')).format( - Cmd=Data.Tokens[0], Percent=RandPercent(), Thing=Data.Body)}) +def percenter(context, data) -> None: + SendMsg(context, {"Text": choice(Locale.__(f'{data.Name}.{"done" if data.Body else "empty"}')).format( + Cmd=data.Tokens[0], Percent=RandPercent(), Thing=data.Body)}) # Module: Multifun # Provides fun trough preprogrammed-text-based toys. -def multifun(Context, Data) -> None: - cmdkey = Data.Name +def multifun(context, data) -> None: + cmdkey = data.Name replyToId = None - if Data.Quoted: - replyFromUid = Data.Quoted.User["Id"] + if data.Quoted: + replyFromUid = data.Quoted.User.Id # TODO work on all platforms for the bot id if int(replyFromUid.split('@')[0]) == int(TelegramId) and 'bot' in Locale.__(cmdkey): Text = choice(Locale.__(f'{cmdkey}.bot')) - elif replyFromUid == Data.User["Id"] and 'self' in Locale.__(cmdkey): - Text = choice(Locale.__(f'{cmdkey}.self')).format(Data.User["Name"]) + elif replyFromUid == data.User.Id and 'self' in Locale.__(cmdkey): + Text = choice(Locale.__(f'{cmdkey}.self')).format(data.User.Name) else: if 'others' in Locale.__(cmdkey): - Text = choice(Locale.__(f'{cmdkey}.others')).format(Data.User["Name"], Data.Quoted.User["Name"]) - replyToId = Data.Quoted.messageId + Text = choice(Locale.__(f'{cmdkey}.others')).format(data.User.Name, data.Quoted.User.Name) + replyToId = data.Quoted.messageId else: if 'empty' in Locale.__(cmdkey): Text = choice(Locale.__(f'{cmdkey}.empty')) - SendMsg(Context, {"Text": Text, "ReplyTo": replyToId}) + SendMsg(context, {"Text": Text, "ReplyTo": replyToId}) # Module: Start -# Salutes the user, for now no other purpose except giving a feel that the bot is working. -def cStart(Context, Data) -> None: - SendMsg(Context, {"Text": choice(Locale.__('start')).format(Data.User['Name'])}) - -# Module: Help -# Provides help for the bot. For now, it just lists the commands. -def cHelp(Context, Data=None) -> None: - Commands = '' - for Cmd in Endpoints.keys(): - Commands += f'* /{Cmd}\n' - SendMsg(Context, {"TextPlain": f'Available Endpoints (WIP):\n{Commands}'}) +# Salutes the user, hinting that the bot is working and providing basic quick help. +def cStart(context, data) -> None: + SendMsg(context, {"Text": choice(Locale.__('start')).format(data.User.Name)}) # Module: Source # Provides a copy of the bot source codes and/or instructions on how to get it. -def cSource(Context, Data=None) -> None: - SendMsg(Context, {"TextPlain": ("""\ -* Original Source Code: {https://gitlab.com/octospacc/WinDog} +def cSource(context, data=None) -> None: + SendMsg(context, {"TextPlain": ("""\ +* Original Code: {https://gitlab.com/octospacc/WinDog} * Mirror: {https://github.com/octospacc/WinDog} -""" + (f"* Modified Source Code: {{{ModifiedSourceUrl}}}" if ModifiedSourceUrl else ""))}) +""" + (f"* Modified Code: {{{ModifiedSourceUrl}}}" if ModifiedSourceUrl else ""))}) # Module: Config # ... @@ -62,28 +54,38 @@ def cSource(Context, Data=None) -> None: # Module: Ping # Responds pong, useful for testing messaging latency. -def cPing(Context, Data=None) -> None: - SendMsg(Context, {"Text": "*Pong!*"}) +def cPing(context, data=None) -> None: + SendMsg(context, {"Text": "*Pong!*"}) # Module: Echo # Responds back with the original text of the received message. -def cEcho(Context, Data) -> None: - if Data.Body: - SendMsg(Context, {"Text": Data.Body}) +def cEcho(context, data) -> None: + if data.Body: + prefix = "🗣️ " + if len(data.Tokens) == 2: + nonascii = True + for char in data.Tokens[1]: + if ord(char) < 256: + nonascii = False + break + if nonascii: + # text is not ascii, probably an emoji (altough not necessarily), so just pass as is (useful for Telegram emojis) + prefix = '' + SendMsg(context, {"Text": (prefix + data.Body)}) else: - SendMsg(Context, {"Text": choice(Locale.__('echo.empty'))}) + SendMsg(context, {"Text": choice(Locale.__('echo.empty'))}) # Module: Broadcast # Sends an admin message over to another destination -def cBroadcast(Context, Data) -> None: - if Data.User['Id'] not in AdminIds: - return SendMsg(Context, {"Text": choice(Locale.__('eval'))}) - if len(Data.Tokens) < 3: - return SendMsg(Context, {"Text": "Bad usage."}) - Dest = Data.Tokens[1] - Text = ' '.join(Data.Tokens[2:]) - SendMsg(Context, {"TextPlain": Text}, Dest) - SendMsg(Context, {"TextPlain": "Executed."}) +def cBroadcast(context, data) -> None: + if data.User.Id not in AdminIds: + return SendMsg(context, {"Text": choice(Locale.__('eval'))}) + if len(data.Tokens) < 3: + return SendMsg(context, {"Text": "Bad usage."}) + Dest = data.Tokens[1] + Text = ' '.join(data.Tokens[2:]) + SendMsg(context, {"TextPlain": Text}, Dest) + SendMsg(context, {"TextPlain": "Executed."}) #def cTime(update:Update, context:CallbackContext) -> None: # update.message.reply_markdown_v2( @@ -92,31 +94,32 @@ def cBroadcast(Context, Data) -> None: # Module: Eval # Execute a Python command (or safe literal operation) in the current context. Currently not implemented. -def cEval(Context, Data=None) -> None: - SendMsg(Context, {"Text": choice(Locale.__('eval'))}) +def cEval(context, data=None) -> None: + SendMsg(context, {"Text": choice(Locale.__('eval'))}) # Module: Exec # Execute a system command from the allowed ones and return stdout/stderr. -def cExec(Context, Data) -> None: - if len(Data.Tokens) >= 2 and Data.Tokens[1].lower() in ExecAllowed: - Cmd = Data.Tokens[1].lower() - Out = subprocess.run(('sh', '-c', f'export PATH=$PATH:/usr/games; {Cmd}'), stdout=subprocess.PIPE).stdout.decode() +def cExec(context, data) -> None: + if len(data.Tokens) >= 2 and data.Tokens[1].lower() in ExecAllowed: + cmd = data.Tokens[1].lower() + Out = subprocess.run(('sh', '-c', f'export PATH=$PATH:/usr/games; {cmd}'), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout.decode() # Caption = (re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])').sub('', Out)) - SendMsg(Context, { + SendMsg(context, { "TextPlain": Caption, "TextMarkdown": MarkdownCode(Caption, True), }) else: - SendMsg(Context, {"Text": choice(Locale.__('eval'))}) + SendMsg(context, {"Text": choice(Locale.__('eval'))}) # Module: Format # Reformat text using an handful of rules. Currently not implemented. -def cFormat(Context, Data=None) -> None: +def cFormat(context, data=None) -> None: pass # Module: Frame # Frame someone's message into a platform-styled image. Currently not implemented. -def cFrame(Context, Data=None) -> None: +def cFrame(context, data=None) -> None: pass diff --git a/ModWinDog/Scripting/Scripting.py b/ModWinDog/Scripting/Scripting.py new file mode 100644 index 0000000..c0dc8cc --- /dev/null +++ b/ModWinDog/Scripting/Scripting.py @@ -0,0 +1,49 @@ +luaCycleLimit = 10000 +luaMemoryLimit = (512 * 1024) # 512 KB +luaCrashMessage = f"Lua Error: Script has been forcefully terminated due to having exceeded the max cycle count limit ({luaCycleLimit})." + +from lupa import LuaRuntime as NewLuaRuntime, LuaError, LuaSyntaxError + +def luaAttributeFilter(obj, attr_name, is_setting): + raise AttributeError("Access Denied.") + +#LuaRuntime = NewLuaRuntime(max_memory=(16 * 1024**2), register_eval=False, register_builtins=False, attribute_filter=luaAttributeFilter) +#for key in LuaRuntime.globals(): +# if key not in ("error", "assert", "math", "type"): +# del LuaRuntime.globals()[key] +#luaGlobalsCopy = dict(LuaRuntime.globals()) # should this manually handle nested stuff? +# this way to prevent overwriting of global fields is flawed since this doesn't protect concurrent scripts +# better to use the currently active solution of a dedicated instance for each new script running +#def luaFunctionRunner(userFunction:callable): +# for key in luaGlobalsCopy: +# LuaRuntime.globals()[key] = luaGlobalsCopy[key] +# return userFunction() + +# TODO make print behave the same as normal Lua, and expose a function for printing without newlines +def cLua(context, data=None) -> None: + scriptText = (data.Body or (data.Quoted and data.Quoted.Body)) + if not scriptText: + return SendMsg(context, {"Text": "You must provide some Lua code to execute."}) + luaRuntime = NewLuaRuntime(max_memory=luaMemoryLimit, register_eval=False, register_builtins=False, attribute_filter=luaAttributeFilter) + luaRuntime.eval(f"""(function() +_windog = {{ stdout = "" }} +function print (text, endl) _windog.stdout = _windog.stdout .. text .. (endl ~= false and "\\n" or "") end +function luaCrashHandler () return error("{luaCrashMessage}") end +debug.sethook(luaCrashHandler, "", {luaCycleLimit}) +end)()""") + for key in luaRuntime.globals(): + if key not in ("error", "assert", "math", "string", "print", "_windog"): + del luaRuntime.globals()[key] + try: + textOutput = ("[ʟᴜᴀ ꜱᴛᴅᴏᴜᴛ]\n\n" + luaRuntime.eval(f"""(function() +_windog.scriptout = (function()\n{scriptText}\nend)() +return _windog.stdout .. (_windog.scriptout or '') +end)()""")) + except (LuaError, LuaSyntaxError) as error: + Log(textOutput := str("Lua Error: " + error)) + SendMsg(context, {"TextPlain": textOutput}) + +RegisterModule(name="Scripting", group="Geek", summary="Tools for programming the bot and expanding its features.", endpoints={ + "Lua": CreateEndpoint(["lua"], summary="Execute a Lua snippet and get its output.", handler=cLua), +}) + diff --git a/ModWinDog/Scripting/requirements.txt b/ModWinDog/Scripting/requirements.txt new file mode 100644 index 0000000..d59a2b6 --- /dev/null +++ b/ModWinDog/Scripting/requirements.txt @@ -0,0 +1 @@ +lupa diff --git a/README.md b/README.md index 7a517fe..1fa1f64 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ WinDog/WinDogBot is a chatbot I've been (lazily) developing for years, with some special characteristics: -* multi-purpose: it's created for doing a myriad of different things, from the funny to the useful. +* multi-purpose: it's created for doing a myriad of different things, from the funny to the useful (moderation features will be implemented in the future). * multi-platform: it's an experiment in automagical multiplatform compatibility, with modules targeting a common abstracted API. +* modular: in all of this, the bot is modular, and allows features to be easily activated or removed at will (like some other ones). The officially-hosted instances of this bot are, respectively: @@ -13,7 +14,7 @@ The officially-hosted instances of this bot are, respectively: In case you want to run your own instance: 1. `git clone --depth 1 https://gitlab.com/octospacc/WinDog && cd ./WinDog` to get the code. -2. `python3 -m pip install -U -r ./requirements.txt` to install dependencies. +2. `find -type f -name requirements.txt -exec python3 -m pip install -U -r {} \;` to install the full package of dependencies. 3. `cp ./LibWinDog/Config.py ./` and, in the new file, edit essential fields like user credentials, then delete the unmodified fields. 4. `sh ./StartWinDog.sh` to start the bot every time. diff --git a/WinDog.py b/WinDog.py index c333a00..2f17a38 100755 --- a/WinDog.py +++ b/WinDog.py @@ -11,7 +11,7 @@ from os import listdir from os.path import isfile, isdir from random import choice, randint from types import SimpleNamespace -#from traceback import format_exc as TraceText +from traceback import format_exc from bs4 import BeautifulSoup from html import unescape as HtmlUnescape from markdown import markdown @@ -19,67 +19,61 @@ from markdown import markdown # MdEscapes = '\\`*_{}[]()<>#+-.!|=' -Db = {"Rooms": {}, "Users": {}} -Locale = {"Fallback": {}} -Platforms = {} -Commands = {} +def Log(text:str, level:str="?") -> None: + print(f"[{level}] [{int(time.time())}] {text}") -for dir in ("LibWinDog/Platforms", "ModWinDog"): - for path in listdir(f"./{dir}"): - path = f"./{dir}/{path}" - if isfile(path): - exec(open(path, 'r').read()) - elif isdir(path): - exec(open(f"{path}/mod.py", 'r').read()) -exec(open("./LibWinDog/Config.py", 'r').read()) - -def SetupLocale() -> None: +def SetupLocales() -> None: global Locale - for File in listdir('./Locale'): - Lang = File.split('.')[0] + for file in listdir('./Locale'): + lang = file.split('.')[0] try: - with open(f'./Locale/{File}') as File: - Locale[Lang] = json.load(File) + with open(f'./Locale/{file}') as file: + Locale[lang] = json.load(file) except Exception: - print(f'Cannot load {Lang} locale, exiting.') + Log(f'Cannot load {lang} locale, exiting.') raise exit(1) - for Key in Locale[DefaultLang]: - Locale['Fallback'][Key] = Locale[DefaultLang][Key] - for Lang in Locale: - for Key in Locale[Lang]: - if not Key in Locale['Fallback']: - Locale['Fallback'][Key] = Locale[Lang][Key] - def __(Key:str, Lang:str=DefaultLang): - Set = None - Key = Key.split('.') + for key in Locale[DefaultLang]: + Locale['Fallback'][key] = Locale[DefaultLang][key] + for lang in Locale: + for key in Locale[lang]: + if not key in Locale['Fallback']: + Locale['Fallback'][key] = Locale[lang][key] + def querier(query:str, lang:str=DefaultLang): + value = None + query = query.split('.') try: - Set = Locale.Locale[Lang] - for El in Key: - Set = Set[El] + value = Locale.Locale[lang] + for key in query: + value = value[key] except Exception: - Set = Locale.Locale['Fallback'] - for El in Key: - Set = Set[El] - return Set - Locale['__'] = __ + value = Locale.Locale['Fallback'] + for key in query: + value = value[key] + return value + Locale['__'] = querier Locale['Locale'] = Locale Locale = SimpleNamespace(**Locale) def SetupDb() -> None: global Db try: - with open('Database.json', 'r') as File: - Db = json.load(File) + with open('Database.json', 'r') as file: + Db = json.load(file) except Exception: pass -def InDict(Dict:dict, Key:str): +def InDict(Dict:dict, Key:str) -> any: if Key in Dict: return Dict[Key] else: return None +def isinstanceSafe(clazz:any, instance:any) -> bool: + if instance != None: + return isinstance(clazz, instance) + return False + def CharEscape(String:str, Escape:str='') -> str: if Escape == 'MARKDOWN': return escape_markdown(String, version=2) @@ -116,18 +110,17 @@ def GetRawTokens(text:str) -> list: return text.strip().replace('\t', ' ').replace(' ', ' ').replace(' ', ' ').split(' ') def ParseCmd(msg) -> dict|None: - name = msg.lower().split(' ')[0][1:].split('@')[0] - if not name: - return + name = msg.replace('\n', ' ').replace('\t', ' ').replace(' ', ' ').replace(' ', ' ').split(' ')[0][1:].split('@')[0] + if not name: return return SimpleNamespace(**{ - "Name": name, + "Name": name.lower(), "Body": name.join(msg.split(name)[1:]).strip(), "Tokens": GetRawTokens(msg), "User": None, "Quoted": None, }) -def GetWeightedText(texts:tuple) -> str: +def GetWeightedText(texts:tuple) -> str|None: for text in texts: if text: return text @@ -136,11 +129,11 @@ def RandPercent() -> int: num = randint(0,100) return (f'{num}.00' if num == 100 else f'{num}.{randint(0,9)}{randint(0,9)}') -def RandHexStr(Len:int) -> str: - Hex = '' - for Char in range(Len): - Hex += choice('0123456789abcdef') - return Hex +def RandHexStr(length:int) -> str: + hexa = '' + for char in range(length): + hexa += choice('0123456789abcdef') + return hexa def SendMsg(Context, Data, Destination=None) -> None: if type(Context) == dict: @@ -159,27 +152,59 @@ def SendMsg(Context, Data, Destination=None) -> None: TextMarkdown = CharEscape(HtmlUnescape(Data['Text']), InferMdEscape(HtmlUnescape(Data['Text']), TextPlain)) for platform in Platforms: platform = Platforms[platform] - if ("eventClass" in platform and isinstance(Event, platform["eventClass"])) \ - or ("managerClass" in platform and isinstance(Manager, platform["managerClass"])): + if isinstanceSafe(Event, InDict(platform, "eventClass")) or isinstanceSafe(Manager, InDict(platform, "managerClass")): platform["sender"](Event, Manager, Data, Destination, TextPlain, TextMarkdown) +def RegisterPlatform(name:str, main:callable, sender:callable, *, eventClass=None, managerClass=None) -> None: + Platforms[name] = {"main": main, "sender": sender, "eventClass": eventClass, "managerClass": managerClass} + Log(f"Registered Platform: {name}.") + +def RegisterModule(name:str, endpoints:dict, *, group:str=None, summary:str=None) -> None: + Modules[name] = {"group": group, "summary": summary, "endpoints": endpoints} + Log(f"Registered Module: {name}.") + for endpoint in endpoints: + endpoint = endpoints[endpoint] + for name in endpoint["names"]: + Endpoints[name] = endpoint["handler"] + +def CreateEndpoint(names:list[str]|tuple[str], handler:callable, *, summary:str=None) -> dict: + return {"names": names, "summary": summary, "handler": handler} + def Main() -> None: SetupDb() - SetupLocale() - TelegramMain() - MastodonMain() - #MatrixMain() - #for platform in Platforms: - # Platforms[platform]["main"]() + SetupLocales() + for platform in Platforms: + Platforms[platform]["main"]() + Log(f"Initialized Platform: {platform}.") + Log('WinDog Ready!') while True: time.sleep(9**9) if __name__ == '__main__': + Log('Starting WinDog...') + Db = {"Rooms": {}, "Users": {}} + Locale = {"Fallback": {}} + Platforms, Modules, Endpoints = {}, {}, {} + + for dir in ("LibWinDog/Platforms", "ModWinDog"): + for name in listdir(f"./{dir}"): + path = f"./{dir}/{name}" + if isfile(path): + exec(open(path, 'r').read()) + elif isdir(path): + exec(open(f"{path}/{name}.py", 'r').read()) + # TODO load locales + #for name in listdir(path): + # if name.lower().endswith('.json'): + # + + Log('Loading Configuration...') + exec(open("./LibWinDog/Config.py", 'r').read()) try: from Config import * except Exception: - pass - print('Starting WinDog...') - Main() - print('Closing WinDog...') + Log(format_exc()) + + Main() + Log('Closing WinDog...') diff --git a/requirements.txt b/requirements.txt index 74edc2a..bd8855b 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,2 @@ -# Program core -# ... - -# Required by some bot modules -urllib3 -urlextract - -# Mastodon support -Mastodon.py beautifulsoup4 Markdown - -# Telegram support -python-telegram-bot==13.4.1 -