diff --git a/LibWinDog/Database.py b/LibWinDog/Database.py index 5768079..a57640a 100755 --- a/LibWinDog/Database.py +++ b/LibWinDog/Database.py @@ -29,9 +29,11 @@ class Room(Entity): Db.create_tables([EntitySettings, User, Room], safe=True) class UserSettingsData(): - def __new__(cls, user_id:str) -> SafeNamespace|None: + def __new__(cls, user_id:str=None) -> SafeNamespace: + settings = None try: - return SafeNamespace(**EntitySettings.select().join(User).where(User.id == user_id).dicts().get()) + settings = EntitySettings.select().join(User).where(User.id == user_id).dicts().get() except EntitySettings.DoesNotExist: - return None + pass + return SafeNamespace(**(settings or {}), _exists=bool(settings)) diff --git a/LibWinDog/Platforms/Mastodon/Mastodon.py b/LibWinDog/Platforms/Mastodon/Mastodon.py index a5e3c62..e94b7ea 100755 --- a/LibWinDog/Platforms/Mastodon/Mastodon.py +++ b/LibWinDog/Platforms/Mastodon/Mastodon.py @@ -16,7 +16,7 @@ import mastodon from bs4 import BeautifulSoup from magic import Magic -def MastodonMain() -> bool: +def MastodonMain(path:str) -> bool: if not (MastodonUrl and MastodonToken): return False Mastodon = mastodon.Mastodon(api_base_url=MastodonUrl, access_token=MastodonToken) @@ -40,7 +40,7 @@ def MastodonMakeInputMessageData(status:dict) -> InputMessageData: id = ("mastodon:" + strip_url_scheme(status["account"]["uri"])), name = status["account"]["display_name"], ) - data.user.settings = (UserSettingsData(data.user.id) or SafeNamespace()) + data.user.settings = UserSettingsData(data.user.id) return data def MastodonHandler(event, Mastodon): diff --git a/LibWinDog/Platforms/Matrix/Matrix.py b/LibWinDog/Platforms/Matrix/Matrix.py index 321b502..f7ad5d5 100755 --- a/LibWinDog/Platforms/Matrix/Matrix.py +++ b/LibWinDog/Platforms/Matrix/Matrix.py @@ -25,7 +25,7 @@ import queue MatrixClient = None MatrixQueue = queue.Queue() -def MatrixMain() -> bool: +def MatrixMain(path:str) -> bool: if not (MatrixUrl and MatrixUsername and (MatrixPassword or MatrixToken)): return False def upgrade_username(new:str): @@ -73,7 +73,7 @@ def MatrixMakeInputMessageData(room:nio.MatrixRoom, event:nio.RoomMessage) -> In _, _, server_name, media_id = mxc_url.split('/') data.media["url"] = ("https://" + server_name + nio.Api.download(server_name, media_id)[1]) data.command = TextCommandData(data.text_plain, "matrix") - data.user.settings = (UserSettingsData(data.user.id) or SafeNamespace()) + data.user.settings = UserSettingsData(data.user.id) return data async def MatrixInviteHandler(room:nio.MatrixRoom, event:nio.InviteEvent) -> None: diff --git a/LibWinDog/Platforms/Telegram/Telegram.py b/LibWinDog/Platforms/Telegram/Telegram.py index e9375ab..8c9a066 100755 --- a/LibWinDog/Platforms/Telegram/Telegram.py +++ b/LibWinDog/Platforms/Telegram/Telegram.py @@ -20,7 +20,7 @@ from telegram.ext import CommandHandler, MessageHandler, Filters, CallbackContex TelegramClient = None -def TelegramMain() -> bool: +def TelegramMain(path:str) -> bool: if not TelegramToken: return False global TelegramClient @@ -55,7 +55,7 @@ def TelegramMakeInputMessageData(message:telegram.Message) -> InputMessageData: ), ) data.command = TextCommandData(data.text_plain, "telegram") - data.user.settings = (UserSettingsData(data.user.id) or SafeNamespace()) + data.user.settings = UserSettingsData(data.user.id) linked = TelegramLinker(data) data.message_url = linked.message data.room.url = linked.room diff --git a/LibWinDog/Platforms/Web/Web.py b/LibWinDog/Platforms/Web/Web.py index 6ce2c38..ad313a9 100755 --- a/LibWinDog/Platforms/Web/Web.py +++ b/LibWinDog/Platforms/Web/Web.py @@ -8,78 +8,191 @@ WebConfig = { "host": ("0.0.0.0", 30264), "url": "https://windog.octt.eu.org", + "anti_drop_interval": 15, } """ # end windog config # """ import queue -from http.server import BaseHTTPRequestHandler -from LibWinDog.Platforms.Web.multithread_http_server import MultiThreadHttpServer +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from threading import Thread +from uuid6 import uuid7 WebQueues = {} - -default_css = """""" +web_css_style = web_js_script = None +web_html_prefix = (lambda document_class='', head_extra='': (f'''
+ + +Initializing... Click here if you are not automatically redirected.
'''.encode()) + self.wfile.write(f'''{web_html_prefix()} +" + WebQueues[uuid].get(block=False).text_html + "
").encode()) + self.wfile.write(WebMakeMessageHtml(WebQueues[room_id][user_id].get(block=False), user_id).encode()) except queue.Empty: time.sleep(0.01) - def do_POST(self): - uuid = self.path.split('/')[-1] - text = urlparse.unquote_plus(self.rfile.read(int(self.headers["Content-Length"])).decode().split('=')[1]) - self.send_response(302) - self.send_header("Location", f"/form/{uuid}") - self.end_headers() - data = WebMakeInputMessageData(text, uuid) - OnInputMessageParsed(data) - call_endpoint(EventContext(platform="web", event=SafeNamespace(room_id=uuid)), data) + def send_form_html(self, room_id:str, user_id:str): + self.send_text_content((f'''{web_html_prefix("form no-margin")} + + +'''), "text/html") -def WebMakeInputMessageData(text:str, uuid:str) -> InputMessageData: - return InputMessageData( + def do_GET(self): + room_id = user_id = None + path, fields, query = self.parse_path() + if not path: + self.init_new_room() + elif path == "windog.css": + self.send_text_content(web_css_style, "text/css") + #elif path == "on-connection-dropped.css": + # self.send_text_content('.on-connection-dropped { display: revert; } form { display: none; }', "text/css", infinite=True) + elif path == "windog.js": + self.send_text_content(web_js_script, "application/javascript") + elif fields[0] == "api": + ... # TODO rest API + elif fields[0] == "form": + self.send_form_html(*fields[1:3]) + else: + room_id, user_id = (fields + [None])[0:2] + if room_id not in WebQueues: + self.init_new_room(room_id if (len(room_id) >= 32) else None) + if user_id: + if user_id not in WebQueues[room_id]: + WebQueues[room_id][user_id] = queue.Queue() + WebPushEvent(room_id, user_id, ".start", self.headers) + Thread(target=lambda:WebAntiDropEnqueue(room_id, user_id)).start() + self.handle_room_chat(room_id, user_id, ("page-target=1" in query)) + else: + self.send_text_content(f'''{web_html_prefix("wrapper no-margin")}''', "text/html") + + def do_POST(self): + path, fields, query = self.parse_path() + if path and fields[0] == 'form': + room_id, user_id = fields[1:3] + self.send_form_html(room_id, user_id) + WebPushEvent(room_id, user_id, urlparse.unquote_plus(self.rfile.read(int(self.headers["Content-Length"])).decode().split('=')[1]), self.headers) + +def WebPushEvent(room_id:str, user_id:str, text:str, headers:dict[str:str]): + context = EventContext(platform="web", event=SafeNamespace(room_id=room_id, user_id=user_id)) + data = InputMessageData( text_plain = text, + text_html = html_escape(text), command = TextCommandData(text, "web"), room = SafeNamespace( - id = f"web:{uuid}", + id = f"web:{room_id}", ), user = UserData( - settings = SafeNamespace(), + id = f"web:{user_id}", + name = ("User" + str(abs(hash('\n'.join([f"{key}: {headers[key]}" for key in headers if key.lower() in ["accept", "accept-language", "accept-encoding", "dnt", "user-agent"]]))) % (10**8))), + settings = UserSettingsData(), ), ) + OnInputMessageParsed(data) + WebSender(context, ObjectUnion(data, {"from_user": True})) + call_endpoint(context, data) -def WebMain() -> None: - server = MultiThreadHttpServer(WebConfig["host"], 32, WebServerClass) - server.start(background=True) +def WebMakeMessageHtml(message:MessageData, for_user_id:str): + if not (message.text_html or message.media): + return "" + user_id = (message.user and message.user.id and message.user.id.split(':')[1]) + # NOTE: this doesn't handle tags with attributes! + if message.text_html and (f"{message.text_html}<".split('>')[0].split('<')[1] not in ['p', 'pre']): + message.text_html = f'{message.text_html}
' + return (f' ') + +def WebMain(path:str) -> bool: + global web_css_style, web_js_script + web_css_style = open(f"{path}/windog.css", 'r').read() + web_js_script = open(f"{path}/windog.js", 'r').read() + Thread(target=lambda:ThreadingHTTPServer(WebConfig["host"], WebServerClass).serve_forever()).start() + return True def WebSender(context:EventContext, data:OutputMessageData) -> None: - WebQueues[context.event.room_id].put(data) + #WebQueues[context.event.room_id][context.event.user_id].put(data) + for user_id in (room := WebQueues[context.event.room_id]): + room[user_id].put(data) + +# prevent browser from closing connection after ~1 minute of inactivity, by continuously sending empty, invisible messages +# TODO maybe there should exist only a single thread with this function handling all queues? otherwise we're probably wasting many threads! +def WebAntiDropEnqueue(room_id:str, user_id:str): + while True: + WebQueues[room_id][user_id].put(OutputMessageData()) + time.sleep(WebConfig["anti_drop_interval"]) RegisterPlatform(name="Web", main=WebMain, sender=WebSender) diff --git a/LibWinDog/Platforms/Web/multithread_http_server.py b/LibWinDog/Platforms/Web/multithread_http_server.py deleted file mode 100755 index 42e92ea..0000000 --- a/LibWinDog/Platforms/Web/multithread_http_server.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python -""" -MIT License - -Copyright (c) 2018 Ortis (cao.ortis.org@gmail.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - - -import socket -import threading -import time -from http.server import HTTPServer -import logging - - -class MultiThreadHttpServer: - - def __init__(self, host, parallelism, http_handler_class, request_callback=None, log=None): - """ - :param host: host to bind. example: '127.0.0.1:80' - :param parallelism: number of thread listener and backlog - :param http_handler_class: the handler class extending BaseHTTPRequestHandler - :param request_callback: callback on incoming request. This method can be accede in the HTTPHandler instance. - Example: self.server.request_callback( - 'GET', # specify http method - self # pass the HTTPHandler instance - ) - """ - - self.host = host - self.parallelism = parallelism - self.http_handler_class = http_handler_class - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.request_callback = request_callback - self.connection_handlers = [] - self.stop_requested = False - self.log = log - - def start(self, background=False): - self.socket.bind(self.host) - self.socket.listen(self.parallelism) - - if self.log is not None: - self.log.debug("Creating "+str(self.parallelism)+" connection handler") - - for i in range(self.parallelism): - ch = ConnectionHandler(self.socket, self.http_handler_class, self.request_callback) - ch.start() - self.connection_handlers.append(ch) - - if background: - if self.log is not None: - self.log.debug("Serving (background thread)") - threading.Thread(target=self.__serve).start() - else: - if self.log is not None: - self.log.debug("Serving (current thread)") - self.__serve() - - def stop(self): - self.stop_requested = True - for ch in self.connection_handlers: - ch.stop() - - def __serve(self): - """ - Serve until stop() is called. Blocking method - :return: - """ - while not self.stop_requested: - time.sleep(1) - - -class ConnectionHandler(threading.Thread, HTTPServer): - - def __init__(self, sock, http_handler_class, request_callback=None): - HTTPServer.__init__(self, sock.getsockname(), http_handler_class, False) - self.socket = sock - self.server_bind = self.server_close = lambda self: None - self.HTTPHandler = http_handler_class - self.request_callback = request_callback - - threading.Thread.__init__(self) - self.daemon = True - self.stop_requested = False - - def stop(self): - self.stop_requested = True - - def run(self): - """ Each thread process request forever""" - self.serve_forever() - - def serve_forever(self): - """ Handle requests until stopped """ - while not self.stop_requested: - self.handle_request() - - print("Finish" + str(threading.current_thread())) - diff --git a/LibWinDog/Platforms/Web/requirements.txt b/LibWinDog/Platforms/Web/requirements.txt new file mode 100644 index 0000000..4841738 --- /dev/null +++ b/LibWinDog/Platforms/Web/requirements.txt @@ -0,0 +1 @@ +uuid6 diff --git a/LibWinDog/Platforms/Web/windog.css b/LibWinDog/Platforms/Web/windog.css new file mode 100644 index 0000000..6bd3477 --- /dev/null +++ b/LibWinDog/Platforms/Web/windog.css @@ -0,0 +1,121 @@ +:root { +--bg-color: #fff; +--bg-color-2: #ddd; +--fg-color: #000; +} +* { + box-sizing: border-box; +} +html.no-margin > body { + margin: 0; +} +body { + color: var(--fg-color); + background-color: var(--bg-color); +} + +html.wrapper > body { + overflow: hidden; +} +html.wrapper > body > iframe { + width: 100vw; + height: 100vh; + border: none; +} + +form > * { + min-height: 2em; + height: 98vh; height: calc(100vh - 1em); +} +form > input[type="text"], form > textarea { + width: 93%; width: calc(100% - 4em); + resize: none; +} +form > input[type="submit"] { + width: 3em; + float: right; +} +/* .on-connection-dropped { + color: red; + margin: 0; + display: none; +} */ + +.sticky-box { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--bg-color); +} +.input-frame { + overflow: auto; + height: 4em; height: calc(4em + 4px); + min-height: 4em; min-height: calc(4em + 4px); + resize: vertical; + border-bottom: 2px dotted var(--fg-color); + padding-top: 1em; + margin-bottom: 1em; +} +.input-frame > iframe { + width: 100%; + height: 100%; height: calc(100% - 1em); + border: none; +} +.message { + margin: 0; + padding-left: 1em; + padding-right: 1em; + text-align: left; + background: linear-gradient(to left, var(--bg-color), var(--bg-color-2)); +} +.message.from-self { + text-align: right; + background: linear-gradient(to left, var(--bg-color-2), var(--bg-color)); +} +.message.blue, .message.color-1 { + background: linear-gradient(to left, var(--bg-color), #ccf); +} +.message.red, .message.color-2 { + background: linear-gradient(to left, var(--bg-color), #fcc); +} +.message.green, .message.color-3 { + background: linear-gradient(to left, var(--bg-color), #dfd); +} +.message.purple, .message.color-4 { + background: linear-gradient(to left, var(--bg-color), #fdf); +} +.message.cyan, .message.color-5 { + background: linear-gradient(to left, var(--bg-color), #dff); +} +.message.yellow, .message.color-6 { + background: linear-gradient(to left, var(--bg-color), #ffd); +} +/* .message:last-child { position: sticky; bottom: 0; } */ +/* TODO .message:last-child-minus-1 { position: sticky; bottom: 0; } */ +.message * { + max-width: 100%; + width: auto; + height: auto; +} +.message p { + word-break: break-word; +} +.message pre { + overflow-x: auto; + padding-bottom: 1em; +} +.message img, +.message video { + max-height: 80vh; max-height: calc(90vh - 6em); +} +.message > .name { + display: inline-block; + padding: 1em; + padding-bottom: 0; + font-style: italic; + font-size: small; +} +#load-target:target { + display: revert !important; + padding-top: 1em; +} diff --git a/LibWinDog/Platforms/Web/windog.js b/LibWinDog/Platforms/Web/windog.js new file mode 100644 index 0000000..d6589e4 --- /dev/null +++ b/LibWinDog/Platforms/Web/windog.js @@ -0,0 +1,30 @@ +window.inputFrameResize = (function(height){ + var frameEl = document.querySelector('.input-frame'); + var frameWindow = frameEl.querySelector('iframe').contentWindow; + var textEl = frameWindow.document.querySelector('form > [name="text"]'); + textEl.style.minHeight = 0; + frameEl.style.height = '1em'; + //if (textEl.scrollHeight > frameWindow.document.documentElement.clientHeight) { + if (textEl.scrollHeight / parseInt(getComputedStyle(textEl).height.slice(0, -2)) < 5) { + frameEl.style.height = ('calc(3em + ' + (textEl.scrollHeight + 4) + 'px)'); + frameEl.dataset.scrollHeightOld = textEl.scrollHeight; + } else { + frameEl.style.height = ('calc(3em + ' + (parseInt(frameEl.dataset.scrollHeightOld) + 4) + 'px)'); + } + textEl.style.minHeight = null; +/* if (!frameEl.dataset.height) { + frameEl.dataset.height = 0; + } + if (frameEl.dataset.height > ) + frameEl.style.height = ('calc(4em + ' + height + 'px)'); + frameEl.dataset.height = height; +*/ +}); +if (document.documentElement.className.split(' ').includes('form')) { + var intervalFocus = setInterval(function(){ + try { + document.querySelector('form > [name="text"]').focus(); + clearInterval(intervalFocus); + } catch(err) {} + }, 100); +} diff --git a/LibWinDog/Types.py b/LibWinDog/Types.py index 1cce058..34a2ca9 100755 --- a/LibWinDog/Types.py +++ b/LibWinDog/Types.py @@ -6,12 +6,21 @@ from types import SimpleNamespace class DictNamespace(SimpleNamespace): + def __init__(self, **kwargs): + for key in kwargs: + if type(kwargs[key]) == dict: + kwargs[key] = self.__class__(**kwargs[key]) + return super().__init__(**kwargs) def __iter__(self): return self.__dict__.__iter__() def __getitem__(self, key): return self.__getattribute__(key) def __setitem__(self, key, value): return self.__setattr__(key, value) + #def __setattr__(self, key, value): + #if type(value) == dict: + #value = self.__class__(**value) + #return super().__setattr__(key, value) class SafeNamespace(DictNamespace): def __getattribute__(self, key): diff --git a/ModWinDog/Base/Base.py b/ModWinDog/Base/Base.py index 673ffa8..cfb18f7 100755 --- a/ModWinDog/Base/Base.py +++ b/ModWinDog/Base/Base.py @@ -18,11 +18,13 @@ UserSettingsLimits = { def cConfig(context:EventContext, data:InputMessageData): language = data.user.settings.language - if not (settings := UserSettingsData(data.user.id)): + if (key := data.command.arguments.get): + key = key.lower() + if (not key) or (key not in UserSettingsLimits): + return send_status_400(context, language) + if not (settings := UserSettingsData(data.user.id))._exists: User.update(settings=EntitySettings.create()).where(User.id == data.user.id).execute() settings = UserSettingsData(data.user.id) - if not (key := data.command.arguments.get) or (key not in UserSettingsLimits): - return send_status_400(context, language) if (value := data.command.body): if len(value) > UserSettingsLimits[key]: return send_status(context, 500, language) @@ -52,7 +54,7 @@ def cPing(context:EventContext, data:InputMessageData): RegisterModule(name="Base", endpoints=[ SafeNamespace(names=["source"], handler=cSource), - SafeNamespace(names=["config"], handler=cConfig, body=False, arguments={ + SafeNamespace(names=["config", "settings"], handler=cConfig, body=False, arguments={ "get": True, }), #SafeNamespace(names=["gdpr"], summary="Operations for european citizens regarding your personal data.", handler=cGdpr), diff --git a/ModWinDog/Echo/Echo.py b/ModWinDog/Echo/Echo.py index e38a643..898cd16 100755 --- a/ModWinDog/Echo/Echo.py +++ b/ModWinDog/Echo/Echo.py @@ -7,7 +7,7 @@ def cEcho(context:EventContext, data:InputMessageData): if not (text := data.command.body): return send_message(context, { "text_html": context.endpoint.get_string("empty", data.user.settings.language)}) - prefix = f'🗣️ ' + prefix = f'🗣️ ' if len(data.command.tokens) == 2: # text is a single word nonascii = True for char in data.command.tokens[1]: diff --git a/ModWinDog/Help/Help.py b/ModWinDog/Help/Help.py index f758efd..2285c8e 100755 --- a/ModWinDog/Help/Help.py +++ b/ModWinDog/Help/Help.py @@ -20,7 +20,8 @@ def cHelp(context:EventContext, data:InputMessageData) -> None: text += (f"\n\n{module}" + (f": {summary}" if summary else '')) for endpoint in endpoints: summary = Modules[module].get_string(f"endpoints.{endpoint.names[0]}.summary", language) - text += (f"\n* {prefix}{', {prefix}'.join(endpoint.names)}" + (f": {summary}" if summary else '')) + text += (f"\n* {prefix}{f', {prefix}'.join(endpoint.names)}" + + (f": {summary}" if summary else '')) text = text.strip() return send_message(context, {"text_html": text}) diff --git a/ModWinDog/Internet/Internet.py b/ModWinDog/Internet/Internet.py index 3e3d751..1d5e2bd 100755 --- a/ModWinDog/Internet/Internet.py +++ b/ModWinDog/Internet/Internet.py @@ -10,6 +10,7 @@ MicrosoftBingSettings = {} """ # end windog config # """ from urlextract import URLExtract +from urllib import parse as urlparse from urllib.request import urlopen, Request def RandomHexString(length:int) -> str: diff --git a/ModWinDog/System/System.py b/ModWinDog/System/System.py index 53348be..01527ba 100755 --- a/ModWinDog/System/System.py +++ b/ModWinDog/System/System.py @@ -29,7 +29,7 @@ def cExec(context:EventContext, data:InputMessageData): def cRestart(context:EventContext, data:InputMessageData): if (data.user.id not in AdminIds) and (data.user.tag not in AdminIds): - return send_message(context, {"text_plain": "Permission denied."}) + return send_status(context, 403, data.user.settings.language) open("./.WinDog.Restart.lock", 'w').close() return send_message(context, {"text_plain": "Bot restart queued."}) diff --git a/WinDog.py b/WinDog.py index f71ac93..3bdb8fc 100755 --- a/WinDog.py +++ b/WinDog.py @@ -328,7 +328,7 @@ def app_main() -> None: #SetupDb() app_log(f"📨️ Initializing Platforms... ", newline=False) for platform in Platforms.values(): - if platform.main(): + if platform.main(f"./LibWinDog/Platforms/{platform.name}"): app_log(f"{platform.name}, ", inline=True) app_log("...Done. ✅️", inline=True, newline=True) app_log("🐶️ WinDog Ready!")