diff --git a/LibWinDog/Config.py b/LibWinDog/Config.py index c50f5c6..c88c359 100755 --- a/LibWinDog/Config.py +++ b/LibWinDog/Config.py @@ -15,13 +15,13 @@ LogToFile = True DumpToConsole = False DumpToFile = False -AdminIds = [ "telegram:123456789", "telegram:634314973", "activitypub:admin@mastodon.example.com", ] +AdminIds = [ "telegram:123456789", "telegram:634314973", "matrix:@admin:matrix.example.com", "matrix:@octt:matrix.org", "activitypub:admin@mastodon.example.com", ] -DefaultLang = "en" +BridgesConfig = [] + +DefaultLanguage = "en" Debug = False CmdPrefixes = ".!/" -# False: ASCII output; True: ANSI Output (must be escaped) -ExecAllowed = {"date": False, "fortune": False, "neofetch": True, "uptime": False} WebUserAgent = "WinDog v.Staging" #ModuleGroups = (ModuleGroups | { diff --git a/LibWinDog/Platforms/Mastodon/Mastodon.py b/LibWinDog/Platforms/Mastodon/Mastodon.py index 05f3f2b..f27c63a 100755 --- a/LibWinDog/Platforms/Mastodon/Mastodon.py +++ b/LibWinDog/Platforms/Mastodon/Mastodon.py @@ -10,7 +10,7 @@ # end windog config # """ -MastodonUrl, MastodonToken = None, None +MastodonUrl = MastodonToken = None import mastodon from bs4 import BeautifulSoup @@ -32,7 +32,6 @@ def MastodonMakeInputMessageData(status:dict) -> InputMessageData: text_html = status["content"], ) data.text_plain = BeautifulSoup(data.text_html, "html.parser").get_text() - data.text_auto = GetWeightedText(data.text_html, data.text_plain) command_tokens = data.text_plain.strip().replace("\t", " ").split(" ") while command_tokens[0].strip().startswith('@') or not command_tokens[0]: command_tokens.pop(0) @@ -51,7 +50,7 @@ def MastodonHandler(event, Mastodon): if (command := ObjGet(data, "command.name")): CallEndpoint(command, EventContext(platform="mastodon", event=event, manager=Mastodon), data) -def MastodonSender(context:EventContext, data:OutputMessageData, destination) -> None: +def MastodonSender(context:EventContext, data:OutputMessageData) -> None: media_results = None if data.media: media_results = [] @@ -69,5 +68,5 @@ def MastodonSender(context:EventContext, data:OutputMessageData, destination) -> visibility=('direct' if context.event['status']['visibility'] == 'direct' else 'unlisted'), ) -RegisterPlatform(name="Mastodon", main=MastodonMain, sender=MastodonSender, managerClass=mastodon.Mastodon) +RegisterPlatform(name="Mastodon", main=MastodonMain, sender=MastodonSender, manager_class=mastodon.Mastodon) diff --git a/LibWinDog/Platforms/Matrix/Matrix.py b/LibWinDog/Platforms/Matrix/Matrix.py index 97b1212..c45d7a7 100755 --- a/LibWinDog/Platforms/Matrix/Matrix.py +++ b/LibWinDog/Platforms/Matrix/Matrix.py @@ -16,11 +16,14 @@ # end windog config # """ -MatrixUrl, MatrixUsername, MatrixPassword, MatrixToken = None, None, None, None -MatrixClient = None +MatrixUrl = MatrixUsername = MatrixPassword = MatrixToken = None -from asyncio import run as asyncio_run, create_task as asyncio_create_task +import asyncio import nio +import queue + +MatrixClient = None +MatrixQueue = []#queue.Queue() def MatrixMain() -> bool: if not (MatrixUrl and MatrixUsername and (MatrixPassword or MatrixToken)): @@ -28,6 +31,13 @@ def MatrixMain() -> bool: def upgrade_username(new:str): global MatrixUsername MatrixUsername = new + async def queue_handler(): + asyncio.ensure_future(queue_handler()) + if not len(MatrixQueue): + # avoid 100% CPU usage ☠️ + time.sleep(0.01) + while len(MatrixQueue): + MatrixSender(*MatrixQueue.pop(0)) async def client_main() -> None: global MatrixClient MatrixClient = nio.AsyncClient(MatrixUrl, MatrixUsername) @@ -37,9 +47,10 @@ def MatrixMain() -> bool: if (bot_id := ObjGet(login, "user_id")): upgrade_username(bot_id) # ensure username is fully qualified for the API await MatrixClient.sync(30000) # resync old messages first to "skip read ones" + asyncio.ensure_future(queue_handler()) MatrixClient.add_event_callback(MatrixMessageHandler, nio.RoomMessage) await MatrixClient.sync_forever(timeout=30000) - Thread(target=lambda:asyncio_run(client_main())).start() + Thread(target=lambda:asyncio.run(client_main())).start() return True def MatrixMakeInputMessageData(room:nio.MatrixRoom, event:nio.RoomMessage) -> InputMessageData: @@ -47,7 +58,8 @@ def MatrixMakeInputMessageData(room:nio.MatrixRoom, event:nio.RoomMessage) -> In message_id = f"matrix:{event.event_id}", datetime = event.server_timestamp, text_plain = event.body, - text_html = event.formatted_body, # note: this could be None + text_html = ObjGet(event, "formatted_body"), # this could be unavailable + media = ({"url": event.url} if ObjGet(event, "url") else None), room = SafeNamespace( id = f"matrix:{room.room_id}", name = room.display_name, @@ -69,8 +81,16 @@ async def MatrixMessageHandler(room:nio.MatrixRoom, event:nio.RoomMessage) -> No if (command := ObjGet(data, "command.name")): CallEndpoint(command, EventContext(platform="matrix", event=SafeNamespace(room=room, event=event), manager=MatrixClient), data) -def MatrixSender(context:EventContext, data:OutputMessageData, destination) -> None: - asyncio_create_task(context.manager.room_send(room_id=context.event.room.room_id, message_type="m.room.message", content={"msgtype": "m.text", "body": data.text_plain})) +def MatrixSender(context:EventContext, data:OutputMessageData): + try: + asyncio.get_event_loop() + except RuntimeError: + MatrixQueue.append((context, data)) + return None + asyncio.create_task(context.manager.room_send( + room_id=(data.room_id or ObjGet(context, "event.room.room_id")), + message_type="m.room.message", + content={"msgtype": "m.text", "body": data.text_plain})) -RegisterPlatform(name="Matrix", main=MatrixMain, sender=MatrixSender) +RegisterPlatform(name="Matrix", main=MatrixMain, sender=MatrixSender, manager_class=(lambda:MatrixClient)) diff --git a/LibWinDog/Platforms/Telegram/Telegram.py b/LibWinDog/Platforms/Telegram/Telegram.py index cf6d436..45250b5 100755 --- a/LibWinDog/Platforms/Telegram/Telegram.py +++ b/LibWinDog/Platforms/Telegram/Telegram.py @@ -12,19 +12,21 @@ TelegramToken = None import telegram, telegram.ext -from telegram import ForceReply, Bot #, Update +from telegram import Bot #, Update #from telegram.helpers import escape_markdown #from telegram.ext import Application, filters, CommandHandler, MessageHandler, CallbackContext from telegram.utils.helpers import escape_markdown from telegram.ext import CommandHandler, MessageHandler, Filters, CallbackContext +TelegramClient = None + def TelegramMain() -> bool: if not TelegramToken: return False - updater = telegram.ext.Updater(TelegramToken) - dispatcher = updater.dispatcher - dispatcher.add_handler(MessageHandler(Filters.text | Filters.command, TelegramHandler)) - updater.start_polling() + global TelegramClient + TelegramClient = telegram.ext.Updater(TelegramToken) + TelegramClient.dispatcher.add_handler(MessageHandler(Filters.text | Filters.command, TelegramHandler)) + TelegramClient.start_polling() #app = Application.builder().token(TelegramToken).build() #app.add_handler(MessageHandler(filters.TEXT | filters.COMMAND, TelegramHandler)) #app.run_polling(allowed_updates=Update.ALL_TYPES) @@ -49,7 +51,6 @@ def TelegramMakeInputMessageData(message:telegram.Message) -> InputMessageData: name = (message.chat.title or message.chat.first_name), ), ) - data.text_auto = GetWeightedText(data.text_markdown, data.text_plain) data.command = ParseCommand(data.text_plain) data.user.settings = (GetUserSettings(data.user.id) or SafeNamespace()) linked = TelegramLinker(data) @@ -69,10 +70,10 @@ def TelegramHandler(update:telegram.Update, context:CallbackContext=None) -> Non CallEndpoint(command, EventContext(platform="telegram", event=update, manager=context), data) Thread(target=handler).start() -def TelegramSender(context:EventContext, data:OutputMessageData, destination): +def TelegramSender(context:EventContext, data:OutputMessageData): result = None - if destination: - result = context.manager.bot.send_message(destination, text=data.text_plain) + if data.room_id: + result = context.manager.bot.send_message(data.room_id, text=data.text_plain) else: replyToId = (data.ReplyTo or context.event.message.message_id) if data.media: @@ -103,5 +104,5 @@ def TelegramLinker(data:InputMessageData) -> SafeNamespace: linked.message = f"https://t.me/c/{room_id}/{message_id}" return linked -RegisterPlatform(name="Telegram", main=TelegramMain, sender=TelegramSender, linker=TelegramLinker, eventClass=telegram.Update) +RegisterPlatform(name="Telegram", main=TelegramMain, sender=TelegramSender, linker=TelegramLinker, event_class=telegram.Update, manager_class=(lambda:TelegramClient)) diff --git a/ModWinDog/Broadcast/Broadcast.py b/ModWinDog/Broadcast/Broadcast.py index cf1de84..7789458 100755 --- a/ModWinDog/Broadcast/Broadcast.py +++ b/ModWinDog/Broadcast/Broadcast.py @@ -7,10 +7,11 @@ def cBroadcast(context:EventContext, data:InputMessageData) -> None: if (data.user.id not in AdminIds) and (data.user.tag not in AdminIds): return SendMessage(context, {"Text": choice(Locale.__('eval'))}) destination = data.command.arguments["destination"] - if not (destination and data.command.body): - return SendMessage(context, {"Text": "Bad usage."}) - SendMessage(context, {"TextPlain": data.command.body}, destination) - SendMessage(context, {"TextPlain": "Executed."}) + text = data.command.body + if not (destination and text): + return SendMessage(context, OutputMessageData(text_plain="Bad usage.")) + SendMessage(context, OutputMessageData(text_plain=text, room_id=destination)) + SendMessage(context, OutputMessageData(text_plain="Executed.")) RegisterModule(name="Broadcast", endpoints=[ SafeNamespace(names=["broadcast"], handler=cBroadcast, arguments={ diff --git a/ModWinDog/Internet/Internet.py b/ModWinDog/Internet/Internet.py index 5cac827..7bd261c 100755 --- a/ModWinDog/Internet/Internet.py +++ b/ModWinDog/Internet/Internet.py @@ -18,15 +18,15 @@ def HttpReq(url:str, method:str|None=None, *, body:bytes=None, headers:dict[str, def cEmbedded(context:EventContext, data:InputMessageData) -> None: if len(data.command.tokens) >= 2: # Find links in command body - Text = (data.text_markdown + ' ' + data.text_plain) - elif data.quoted and data.quoted.text_auto: + text = (data.text_markdown + ' ' + data.text_plain) + elif (quoted := data.quoted) and (quoted.text_auto or quoted.text_markdown or quoted.text_html): # Find links in quoted message - Text = (data.quoted.text_markdown + ' ' + data.quoted.text_plain) + text = ((quoted.text_markdown or '') + ' ' + (quoted.text_plain or '') + ' ' + (quoted.text_html or '')) else: # TODO Error message return pass - urls = URLExtract().find_urls(Text) + urls = URLExtract().find_urls(text) if len(urls) > 0: proto = 'https://' url = urls[0] @@ -47,7 +47,7 @@ def cEmbedded(context:EventContext, data:InputMessageData) -> None: elif urlDomain == "vm.tiktok.com": urlDomain = "vm.vxtiktok.com" url = (urlDomain + '/' + '/'.join(url.split('/')[1:])) - SendMessage(context, {"TextPlain": f"{{{proto}{url}}}"}) + SendMessage(context, {"text_plain": f"{{{proto}{url}}}"}) # else TODO error message? def cWeb(context:EventContext, data:InputMessageData) -> None: @@ -79,13 +79,13 @@ def cNews(context:EventContext, data:InputMessageData) -> None: pass def cTranslate(context:EventContext, data:InputMessageData) -> None: + instances = ["lingva.ml", "lingva.lunar.icu"] language_to = data.command.arguments["language_to"] text_input = (data.command.body or (data.quoted and data.quoted.text_plain)) if not (text_input and language_to): return SendMessage(context, {"TextPlain": f"Usage: /translate "}) try: - # TODO: Use many different public Lingva instances in rotation to avoid overloading a specific one - result = json.loads(HttpReq(f'https://lingva.ml/api/v1/auto/{language_to}/{urlparse.quote(text_input)}').read()) + result = json.loads(HttpReq(f'https://{randchoice(instances)}/api/v1/auto/{language_to}/{urlparse.quote(text_input)}').read()) SendMessage(context, {"TextPlain": f"[{result['info']['detectedSource']} (auto) -> {language_to}]\n\n{result['translation']}"}) except Exception: raise diff --git a/ModWinDog/System/System.py b/ModWinDog/System/System.py index a7e7c6c..d8d43ae 100644 --- a/ModWinDog/System/System.py +++ b/ModWinDog/System/System.py @@ -3,6 +3,13 @@ # Licensed under AGPLv3 by OctoSpacc # # ==================================== # +""" # windog config start # """ + +# False: ASCII output; True: ANSI Output (must be escaped) +ExecAllowed = {"date": False, "fortune": False, "neofetch": True, "uptime": False} + +""" # end windog config # """ + import subprocess from re import compile as re_compile diff --git a/README.md b/README.md index 91db0c7..68a0d72 100755 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ WinDog/WinDogBot is a chatbot I've been (lazily) developing for years, with some The officially-hosted instances of this bot are, respectively: * [@WinDogBot](https://t.me/WinDogBot) on Telegram +* [@windog:matrix.org](https://matrix.to/#/@windog:matrix.org) on Matrix * [@WinDog@botsin.space](https://botsin.space/@WinDog) on Mastodon (can also be used from any other Fediverse platform) In case you want to run your own instance: diff --git a/WinDog.py b/WinDog.py index def9912..b6a7f0e 100755 --- a/WinDog.py +++ b/WinDog.py @@ -13,8 +13,8 @@ from os import listdir from os.path import isfile, isdir from random import choice, choice as randchoice, randint from threading import Thread -from traceback import format_exc -from urllib import parse as urlparse +from traceback import format_exc, format_exc as traceback_format_exc +from urllib import parse as urlparse, urllib_parse from yaml import load as yaml_load, BaseLoader as yaml_BaseLoader from bs4 import BeautifulSoup from markdown import markdown @@ -25,12 +25,16 @@ from LibWinDog.Database import * # MdEscapes = '\\`*_{}[]()<>#+-.!|=' -def NamespaceUnion(namespaces:list|tuple, clazz=SimpleNamespace): +def ObjectUnion(*objects:object, clazz:object=None): dikt = {} - for namespace in namespaces: - for key, value in tuple(namespace.__dict__.items()): + auto_clazz = None + for obj in objects: + if type(obj) == dict: + obj = (clazz or SafeNamespace)(**obj) + for key, value in tuple(obj.__dict__.items()): dikt[key] = value - return clazz(**dikt) + auto_clazz = obj.__class__ + return (clazz or auto_clazz)(**dikt) def Log(text:str, level:str="?", *, newline:bool|None=None, inline:bool=False) -> None: endline = '\n' @@ -53,13 +57,13 @@ def SetupLocales() -> None: Log(f'Cannot load {lang} locale, exiting.') raise exit(1) - for key in Locale[DefaultLang]: - Locale['Fallback'][key] = Locale[DefaultLang][key] + for key in Locale[DefaultLanguage]: + Locale['Fallback'][key] = Locale[DefaultLanguage][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): + def querier(query:str, lang:str=DefaultLanguage): value = None query = query.split('.') try: @@ -106,7 +110,7 @@ def isinstanceSafe(clazz:any, instance:any, /) -> bool: return False def get_string(bank:dict, query:str|dict, lang:str=None, /): - if not (result := ObjGet(bank, f"{query}.{lang or DefaultLang}")): + if not (result := ObjGet(bank, f"{query}.{lang or DefaultLanguage}")): if not (result := ObjGet(bank, f"{query}.en")): result = ObjGet(bank, query) return result @@ -199,6 +203,13 @@ def ParseCommand(text:str) -> SafeNamespace|None: def OnMessageParsed(data:InputMessageData) -> None: DumpMessage(data) UpdateUserDb(data.user) + for bridge in BridgesConfig: + if data.room.id in bridge: + rooms = list(bridge) + rooms.remove(data.room.id) + for room in rooms: + tokens = room.split(':') + SendMessage(SafeNamespace(platform=tokens[0]), ObjectUnion(data, {"room_id": ':'.join(tokens)})) def UpdateUserDb(user:SafeNamespace) -> None: try: @@ -214,14 +225,14 @@ def UpdateUserDb(user:SafeNamespace) -> None: def DumpMessage(data:InputMessageData) -> None: if not (Debug and (DumpToFile or DumpToConsole)): return - text = (data.text_plain.replace('\n', '\\n') if data.text_auto else '') + text = (data.text_plain.replace('\n', '\\n') if data.text_plain else '') text = f"[{int(time.time())}] [{time.ctime()}] [{data.room and data.room.id}] [{data.message_id}] [{data.user.id}] {text}" if DumpToConsole: print(text, data) if DumpToFile: open((DumpToFile if (DumpToFile and type(DumpToFile) == str) else "./Dump.txt"), 'a').write(text + '\n') -def SendMessage(context:EventContext, data:OutputMessageData, destination=None) -> None: +def SendMessage(context:EventContext, data:OutputMessageData) -> None: data = (OutputMessageData(**data) if type(data) == dict else data) # TODO remove this after all modules are changed @@ -244,10 +255,18 @@ def SendMessage(context:EventContext, data:OutputMessageData, destination=None) #data.text_html = ??? if data.media: data.media = SureArray(data.media) - #for platform in Platforms.values(): - # if isinstanceSafe(context.event, platform.eventClass) or isinstanceSafe(context.manager, platform.managerClass): - # return platform.sender(context, data, destination) - return Platforms[context.platform].sender(context, data, destination) + if data.room_id: + tokens = data.room_id.split(':') + if tokens[0] != context.platform: + context.platform = tokens[0] + context.manager = context.event = None + data.room_id = ':'.join(tokens[1:]) + if context.platform not in Platforms: + return None + platform = Platforms[context.platform] + if (not context.manager) and (manager := platform.manager_class): + context.manager = (manager() if callable(manager) else manager) + return platform.sender(context, data) def SendNotice(context:EventContext, data) -> None: pass @@ -255,8 +274,8 @@ def SendNotice(context:EventContext, data) -> None: def DeleteMessage(context:EventContext, data) -> None: pass -def RegisterPlatform(name:str, main:callable, sender:callable, linker:callable=None, *, eventClass=None, managerClass=None) -> None: - Platforms[name.lower()] = SafeNamespace(main=main, sender=sender, linker=linker, eventClass=eventClass, managerClass=managerClass) +def RegisterPlatform(name:str, main:callable, sender:callable, linker:callable=None, *, event_class=None, manager_class=None) -> None: + Platforms[name.lower()] = SafeNamespace(main=main, sender=sender, linker=linker, event_class=event_class, manager_class=manager_class) Log(f"{name}, ", inline=True) def RegisterModule(name:str, endpoints:dict, *, group:str|None=None) -> None: