From 90fbf5033c2e136374961e0ce9b9d5c85397f24c Mon Sep 17 00:00:00 2001 From: octospacc Date: Wed, 22 Jan 2025 01:24:08 +0100 Subject: [PATCH] Start work on RPC HTTP API, add call endpoint --- LibWinDog/Platforms/Web/Web.py | 55 +++++++++++++++++++++++++++++----- ModWinDog/Dumper/Dumper.py | 16 ++-------- WinDog.py | 28 +++++++++++++---- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/LibWinDog/Platforms/Web/Web.py b/LibWinDog/Platforms/Web/Web.py index a08cbb0..92c03ad 100755 --- a/LibWinDog/Platforms/Web/Web.py +++ b/LibWinDog/Platforms/Web/Web.py @@ -11,12 +11,16 @@ WebConfig = { "anti_drop_interval": 15, } +WebTokens = {} + """ # end windog config # """ import queue from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from threading import Thread from uuid6 import uuid7 +#from secrets import token_urlsafe +from urllib.parse import parse_qsl WebQueues = {} web_css_style = web_js_script = None @@ -25,18 +29,21 @@ web_html_prefix = (lambda document_class='', head_extra='': (f''' WinDog 🐶️ - + {head_extra}''')) class WebServerClass(BaseHTTPRequestHandler): def parse_path(self): path = self.path.strip('/').lower() try: - query = path.split('?')[1].split('&') + query = path.split('?')[1] + params = dict(parse_qsl(query)) + query = query.split('&') except Exception: query = [] + params = {} path = path.split('?')[0] - return (path, path.split('/'), query) + return (path, path.split('/'), query, params) def do_redirect(self, path:str): self.send_response(302) @@ -111,17 +118,19 @@ class WebServerClass(BaseHTTPRequestHandler): def do_GET(self): room_id = user_id = None - path, fields, query = self.parse_path() + path, fields, query, params = self.parse_path() if not path: self.init_new_room() + elif path == "favicon.ico": + self.send_response(404) 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[:2] == ["api", "v1"]: + self.handle_api("GET", fields[2:], params) elif fields[0] == "form": self.send_form_html(*fields[1:3]) else: @@ -138,11 +147,43 @@ class WebServerClass(BaseHTTPRequestHandler): self.send_text_content(f'''{web_html_prefix("wrapper no-margin")}''', "text/html") def do_POST(self): - path, fields, query = self.parse_path() + path, fields, query, params = 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) + elif fields[:2] == ["api", "v1"]: + self.handle_api("POST", fields[2:], params) + + def parse_command_arguments_web(self, params:dict): + args = SafeNamespace() + for key in params: + if key.startswith('_'): + args[key[1:]] = params[key] + return args + + def handle_api(self, verb:str, fields:list, params:dict): + fields.append('') + match (method := fields[0].lower()): + case "call" if (text := obj_get(params, "text")) or (endpoint := obj_get(params, "endpoint")): + result = call_endpoint(EventContext(), InputMessageData( + command = TextCommandData(text) if text else SafeNamespace( + name = endpoint, + arguments = self.parse_command_arguments_web(params), + body = obj_get(params, 'body') + ), + user = UserData( + settings = UserSettingsData(), + ), + )) + if result: + self.send_text_content(data_to_json(result), "application/json") + return + #case "getmessage": + #case "sendmessage": + #case "endpoints": + self.send_response(404) + self.end_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)) diff --git a/ModWinDog/Dumper/Dumper.py b/ModWinDog/Dumper/Dumper.py index edb9780..88fd090 100755 --- a/ModWinDog/Dumper/Dumper.py +++ b/ModWinDog/Dumper/Dumper.py @@ -3,18 +3,7 @@ # Licensed under AGPLv3 by OctoSpacc # # ==================================== # -from json import dumps as json_dumps, loads as json_loads - -def dict_filter_meta(dikt:dict): - remove = [] - for key in dikt: - if key.startswith('_'): - remove.append(key) - elif type(obj := dikt[key]) == dict: - dikt[key] = dict_filter_meta(obj) - for key in remove: - dikt.pop(key) - return dikt +from json import dumps as json_dumps # TODO: assume current room when none specified def get_message_wrapper(context:EventContext, data:InputMessageData): @@ -25,8 +14,7 @@ def get_message_wrapper(context:EventContext, data:InputMessageData): def cDump(context:EventContext, data:InputMessageData): if not (message := (data.quoted or get_message_wrapper(context, data))): return send_status_400(context, data.user.settings.language) - data = dict_filter_meta(json_loads(json_dumps(message, default=(lambda obj: (obj.__dict__ if not callable(obj) else None))))) - text = json_dumps(data, indent=" ") + text = data_to_json(message, indent=" ") return send_message(context, {"text_html": f'
{html_escape(text)}
'}) def cGetMessage(context:EventContext, data:InputMessageData): diff --git a/WinDog.py b/WinDog.py index 5f386d2..49a45d2 100755 --- a/WinDog.py +++ b/WinDog.py @@ -44,6 +44,22 @@ def get_exception_text(full:bool=False): def good_yaml_load(text:str): return yaml_load(text.replace("\t", " "), Loader=yaml_BaseLoader) +def data_to_dict(data:object): + def dict_filter_meta(dikt:dict): + remove = [] + for key in dikt: + if key.startswith('_'): + remove.append(key) + elif type(obj := dikt[key]) == dict: + dikt[key] = dict_filter_meta(obj) + for key in remove: + dikt.pop(key) + return dikt + return dict_filter_meta(json.loads(json.dumps(data, default=(lambda obj: (obj.__dict__ if not callable(obj) else None))))) + +def data_to_json(data:object, **jsonargs): + return json.dumps(data_to_dict(data), **jsonargs) + def get_string(bank:dict, query:str, lang:str=None) -> str|list[str]|None: if type(result := obj_get(bank, query)) != str: if not (result := obj_get(bank, f"{query}.{lang or DefaultLanguage}")): @@ -103,7 +119,7 @@ def parse_command_arguments(command, endpoint, count:int=None): index += 1 return [arguments, body] -def TextCommandData(text:str, platform:str) -> CommandData|None: +def TextCommandData(text:str, platform:str=None) -> CommandData|None: if not text: return None text = text.strip() @@ -116,7 +132,7 @@ def TextCommandData(text:str, platform:str) -> CommandData|None: command.tokens = text.split() command.prefix = command.tokens[0][0] command.name, command_target = (command.tokens[0][1:].lower().split('@') + [''])[:2] - if command_target and not (command_target == call_or_return(Platforms[platform].agent_info).tag.lower()): + if command_target and platform and not (command_target == call_or_return(Platforms[platform].agent_info).tag.lower()): return None command.body = text[len(command.tokens[0]):].strip() if not (endpoint := obj_get(Endpoints, command.name)): @@ -192,7 +208,7 @@ def send_status(context:EventContext, code:int, lang:str=None, extra:str=None, p return send_message(context, {"text_html": ( (((f'{global_string(f"statuses.{code}.icon")} {global_string("error") if code >= 400 else ""}'.strip() + f' {code}: {global_string(f"statuses.{code}.title")}. {summary_text if summary else ""}').strip()) if preamble else '') - + '\n\n' + (extra or "")).strip()}) + + '\n\n' + (extra or "")).strip()}, status=code) def send_status_400(context:EventContext, lang:str=None, extra:str=None): return send_status(context, 400, lang, @@ -223,7 +239,7 @@ def get_message(context:EventContext, data:InputMessageData) -> InputMessageData "room": {"id": data.room.id, "url": linked.room}, "message_url": linked.message}) -def send_message(context:EventContext, data:OutputMessageData, *, from_sent:bool=False): +def send_message(context:EventContext, data:OutputMessageData, *, from_sent:bool=False, status:int=200): context = ObjectClone(context) data = (OutputMessageData(**data) if type(data) == dict else data) if data.text_html and not data.text_plain: @@ -243,7 +259,7 @@ def send_message(context:EventContext, data:OutputMessageData, *, from_sent:bool if data.ReplyTo: # TODO decide if this has to be this way data.ReplyTo = ':'.join(data.ReplyTo.split(':')[1:]) if context.platform not in Platforms: - return None + return ObjectUnion(data, {"status": status}) platform = Platforms[context.platform] if (not context.manager) and (manager := platform.manager_class): context.manager = call_or_return(manager) @@ -301,7 +317,7 @@ def call_endpoint(context:EventContext, data:InputMessageData): context.data = data context.module = endpoint.module context.endpoint = endpoint - if callable(agent_info := Platforms[context.platform].agent_info): + if context.platform and callable(agent_info := Platforms[context.platform].agent_info): Platforms[context.platform].agent_info = agent_info() return endpoint.handler(context, data)