diff --git a/LibWinDog/Platforms/Matrix/Matrix.py b/LibWinDog/Platforms/Matrix/Matrix.py index c31b7a9..c21f6fa 100755 --- a/LibWinDog/Platforms/Matrix/Matrix.py +++ b/LibWinDog/Platforms/Matrix/Matrix.py @@ -56,7 +56,7 @@ def MatrixMain(path:str) -> bool: def MatrixMakeInputMessageData(room:nio.MatrixRoom, event:nio.RoomMessage) -> InputMessageData: data = InputMessageData( message_id = f"matrix:{event.event_id}", - datetime = event.server_timestamp, + timestamp = event.server_timestamp, text_plain = event.body, text_html = obj_get(event, "formatted_body"), # this could be unavailable media = ({"url": event.url} if obj_get(event, "url") else None), diff --git a/LibWinDog/Platforms/Telegram/Telegram.py b/LibWinDog/Platforms/Telegram/Telegram.py index eb97824..263920f 100755 --- a/LibWinDog/Platforms/Telegram/Telegram.py +++ b/LibWinDog/Platforms/Telegram/Telegram.py @@ -16,11 +16,14 @@ TelegramToken = None TelegramGetterChannel = TelegramGetterGroup = None import telegram, telegram.ext -from telegram import 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 +from base64 import urlsafe_b64encode +from hashlib import sha256 +from hmac import new as hmac_new TelegramClient = None @@ -45,20 +48,25 @@ def TelegramMakeUserData(user:telegram.User) -> UserData: name = user.first_name, ) -def TelegramMakeInputMessageData(message:telegram.Message) -> InputMessageData: +def TelegramMakeInputMessageData(message:telegram.Message, access_token:str=None) -> InputMessageData: #if not message: # return None + timestamp = int(time.mktime(message.date.timetuple())) media = None if (photo := (message.photo and message.photo[-1])): - media = {"url": photo.file_id, "type": "image/"} + media = {"url": photo.file_id, "type": "image/jpeg"} elif (video_note := message.video_note): - media = {"url": video_note.file_id, "type": "video/"} + media = {"url": video_note.file_id, "type": "video/mp4"} elif (media := (message.video or message.voice or message.audio or message.document or message.sticker)): - media = {"url": media.file_id, "type": media.mime_type} + media = {"url": media.file_id, "type": obj_get(media, "mime_type")} + if (file_id := obj_get(media, "url")): + media["url"] = f"telegram:{file_id}" + if access_token: + media["token"] = get_media_token_hash(media["url"], timestamp, access_token) data = InputMessageData( id = f"telegram:{message.message_id}", message_id = f"telegram:{message.message_id}", - datetime = int(time.mktime(message.date.timetuple())), + timestamp = timestamp, text_plain = (message.text or message.caption), text_markdown = message.text_markdown_v2, media = media, @@ -88,14 +96,14 @@ def TelegramHandler(update:telegram.Update, context:CallbackContext=None) -> Non call_endpoint(EventContext(platform="telegram", event=update, manager=context), data) Thread(target=handler).start() -def TelegramGetter(context:EventContext, data:InputMessageData) -> InputMessageData: +def TelegramGetter(context:EventContext, data:InputMessageData, access_token:str=None) -> InputMessageData: # bot API doesn't allow direct access of messages, # so we ask the server to copy it to a service channel, so that the API returns its data, then delete the copy message = TelegramMakeInputMessageData( context.manager.bot.forward_message( message_id=data.message_id, from_chat_id=data.room.id, - chat_id=TelegramGetterChannel)) + chat_id=TelegramGetterChannel), access_token) delete_message(context, message) return message @@ -112,7 +120,7 @@ def TelegramSender(context:EventContext, data:OutputMessageData): "reply_photo" if medium.type.startswith("image/") else "reply_video" if medium.type.startswith("video/") else "reply_document"))( - (medium.bytes or medium.url), + (medium.bytes or medium.url.removeprefix("telegram:")), caption=(data.text_html or data.text_markdown or data.text_plain), parse_mode=("HTML" if data.text_html else "MarkdownV2" if data.text_markdown else None), reply_to_message_id=replyToId) @@ -125,7 +133,15 @@ def TelegramSender(context:EventContext, data:OutputMessageData): return TelegramMakeInputMessageData(result) def TelegramDeleter(context:EventContext, data:MessageData): - context.manager.bot.delete_message(chat_id=data.room.id, message_id=data.message_id) + return context.manager.bot.delete_message(chat_id=data.room.id, message_id=data.message_id) + +def TelegramFileGetter(context:EventContext, file_id:str, out=None): + try: + file = context.manager.bot.get_file(file_id) + return (lambda: file.download(out=out)) if out else file.download_as_bytearray() + #return file.download(out=out) if out else file.download_as_bytearray() + except Exception: + return None # TODO support usernames # TODO remove the platform stripping here (after modifying above functions here that use it), it's now implemented in get_link @@ -148,6 +164,7 @@ register_platform( linker=TelegramLinker, sender=TelegramSender, deleter=TelegramDeleter, + filegetter=TelegramFileGetter, event_class=telegram.Update, manager_class=(lambda:TelegramClient), agent_info=(lambda:TelegramMakeUserData(TelegramClient.bot.get_me())), diff --git a/LibWinDog/Platforms/Web/Web.py b/LibWinDog/Platforms/Web/Web.py index 92c03ad..baff4e3 100755 --- a/LibWinDog/Platforms/Web/Web.py +++ b/LibWinDog/Platforms/Web/Web.py @@ -16,6 +16,9 @@ WebTokens = {} """ # end windog config # """ import queue +from base64 import urlsafe_b64encode +from hashlib import sha256 +from hmac import new as hmac_new from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from threading import Thread from uuid6 import uuid7 @@ -34,11 +37,11 @@ web_html_prefix = (lambda document_class='', head_extra='': (f''' class WebServerClass(BaseHTTPRequestHandler): def parse_path(self): - path = self.path.strip('/').lower() + path = self.path.strip('/') try: query = path.split('?')[1] params = dict(parse_qsl(query)) - query = query.split('&') + query = query.lower().split('&') except Exception: query = [] params = {} @@ -123,6 +126,7 @@ class WebServerClass(BaseHTTPRequestHandler): self.init_new_room() elif path == "favicon.ico": self.send_response(404) + self.end_headers() elif path == "windog.css": self.send_text_content(web_css_style, "text/css") #elif path == "on-connection-dropped.css": @@ -155,6 +159,45 @@ class WebServerClass(BaseHTTPRequestHandler): elif fields[:2] == ["api", "v1"]: self.handle_api("POST", fields[2:], params) + def handle_api(self, verb:str, fields:list, params:dict): + result = None + if (access_token := self.headers["Authorization"]): + access_token = ' '.join(access_token.split(' ')[1:]) if access_token.startswith("Bearer ") else None + else: + access_token = obj_get(params, "authorization") + fields.append('') + match fields[0]: + 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"), + tokens = [], + ), + user = UserData( + settings = UserSettingsData(), + ), + )) + case "GetMessage" if (auth := self.check_web_auth(access_token)) and (message_id := obj_get(params, "message_id")) and (room_id := obj_get(params, "room_id")): + if (type(auth) == bool) or ((type(auth) == list) and (room_id in auth)): + result = get_message(EventContext(), {"message_id": message_id, "room": {"id": room_id}}, access_token) + #case "sendmessage": + #case "endpoints": + case "FileProxy" if (url := obj_get(params, "url")) and (timestamp := obj_get(params, "timestamp")) and (token := obj_get(params, "token")): + if self.validate_file_token(url, timestamp, token) and (passtrough := get_file(EventContext(), url, self.wfile)): + self.send_response(200) + if (filetype := obj_get(params, "type")): + self.send_header("Content-Type", filetype) + self.end_headers() + passtrough() + return + if result: + self.send_text_content(data_to_json(result), "application/json") + else: + self.send_response(404) + self.end_headers() + def parse_command_arguments_web(self, params:dict): args = SafeNamespace() for key in params: @@ -162,28 +205,16 @@ class WebServerClass(BaseHTTPRequestHandler): 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 check_web_auth(self, token:str): + if token and (identity := obj_get(WebTokens, token)): + return True if (identity["owners"] == AdminIds) else identity["room_whitelist"] + return False + + def validate_file_token(self, url:str, timestamp:int, file_token:str): + for token in WebTokens: + if urlsafe_b64encode(hmac_new(token.encode(), f"{url}:{timestamp}".encode(), sha256).digest()).decode() == file_token: + return True + return False 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 88fd090..1f8aab9 100755 --- a/ModWinDog/Dumper/Dumper.py +++ b/ModWinDog/Dumper/Dumper.py @@ -10,13 +10,27 @@ def get_message_wrapper(context:EventContext, data:InputMessageData): if check_bot_admin(data.user) and (message_id := data.command.arguments.message_id) and (room_id := (data.command.arguments.room_id or data.room.id)): return get_message(context, {"message_id": message_id, "room": {"id": room_id}}) -# TODO work with links to messages +# TODO dump and getmessage should work with links to messages! + 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) text = data_to_json(message, indent=" ") return send_message(context, {"text_html": f'
{html_escape(text)}'}) +def cGetLink(context:EventContext, data:InputMessageData): + if not (message := (data.quoted or get_message_wrapper(context, data))): + return send_status_400(context, data.user.settings.language) + text = '' + if (url := message.message_url): + text += f"Message: {url}\n" + if (media := message.media) and (url := media.url): + link = get_media_link(url, type=media.type, timestamp=message.timestamp, access_token=tuple(WebTokens)[0]) + text += f"Media: {link or url}\n" + if not text: + return send_status_400(context, data.user.settings.language) + return send_message(context, {"text_plain": text}) + def cGetMessage(context:EventContext, data:InputMessageData): if not (message := get_message_wrapper(context, data)): return send_status_400(context, data.user.settings.language) @@ -27,6 +41,10 @@ register_module(name="Dumper", group="Geek", endpoints=[ "message_id": True, "room_id": True, }), + SafeNamespace(names=["getlink"], handler=cGetLink, quoted=True, arguments={ + "message_id": True, + "room_id": True, + }), SafeNamespace(names=["getmessage"], handler=cGetMessage, arguments={ "message_id": True, "room_id": True, diff --git a/WinDog.py b/WinDog.py index 49a45d2..a1e963d 100755 --- a/WinDog.py +++ b/WinDog.py @@ -220,7 +220,7 @@ def send_status_error(context:EventContext, lang:str=None, code:int=500, extra:s app_log() return result -def get_link(context:EventContext, data:InputMessageData) -> InputMessageData: +def get_link(context:EventContext, data:InputMessageData): data = (InputMessageData(**data) if type(data) == dict else data) if (data.room and data.room.id): data.room.id = data.room.id.removeprefix(f"{context.platform}:") @@ -230,9 +230,28 @@ def get_link(context:EventContext, data:InputMessageData) -> InputMessageData: data.id = data.id.removeprefix(f"{context.platform}:") return Platforms[context.platform].linker(data) -def get_message(context:EventContext, data:InputMessageData) -> InputMessageData: +def get_media_token_hash(url:str, timestamp:int, access_token:str): + return urlsafe_b64encode(hmac_new(access_token.encode(), f"{url}:{timestamp}".encode(), sha256).digest()).decode() + +def get_media_link(url:str, type:str=None, timestamp:int=None, access_token:str=None): + urllow = url.lower() + if not (urllow.startswith('http') or urllow.startswith('https') or urllow.startswith('/')): + if not (timestamp and access_token): + return None + url = WebConfig["url"] + f"/api/v1/FileProxy/?url={url}&type={type or ''}×tamp={timestamp}&token={get_media_token_hash(url, timestamp, access_token)}" + return url + +def get_message(context:EventContext, data:InputMessageData, access_token:str=None) -> InputMessageData: data = (InputMessageData(**data) if type(data) == dict else data) - message = Platforms[context.platform].getter(context, data) + 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:]) + platform = Platforms[context.platform] + if (not context.manager) and (manager := platform.manager_class): + context.manager = call_or_return(manager) + message = platform.getter(context, data, access_token) linked = get_link(context, data) return ObjectUnion(message, { "message_id": data.message_id, @@ -259,6 +278,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: + # platform has no handler, so instead of doing a send action just return data to caller return ObjectUnion(data, {"status": status}) platform = Platforms[context.platform] if (not context.manager) and (manager := platform.manager_class): @@ -280,8 +300,18 @@ def delete_message(context:EventContext, data:MessageData): data.id = data.id.removeprefix(f"{context.platform}:") return Platforms[context.platform].deleter(context, data) -def register_platform(name:str, main:callable, sender:callable, getter:callable=None, linker:callable=None, deleter:callable=None, *, event_class=None, manager_class=None, agent_info=None) -> None: - Platforms[name.lower()] = SafeNamespace(name=name, main=main, getter=getter, linker=linker, sender=sender, deleter=deleter, event_class=event_class, manager_class=manager_class, agent_info=agent_info) +def get_file(context:EventContext, url:str, out=None): + tokens = url.split(':') + if tokens[0] != context.platform: + context.platform = tokens[0] + context.manager = context.event = None + platform = Platforms[context.platform] + if (not context.manager) and (manager := platform.manager_class): + context.manager = call_or_return(manager) + return platform.filegetter(context, ':'.join(tokens[1:]), out) + +def register_platform(name:str, main:callable, getter:callable=None, linker:callable=None, sender:callable=None, deleter:callable=None, filegetter:callable=None, *, event_class=None, manager_class=None, agent_info=None) -> None: + Platforms[name.lower()] = SafeNamespace(name=name, main=main, getter=getter, linker=linker, sender=sender, deleter=deleter, filegetter=filegetter, event_class=event_class, manager_class=manager_class, agent_info=agent_info) app_log(f"{name}, ", inline=True) def register_module(name:str, endpoints:dict, *, group:str|None=None) -> None: