mirror of
https://gitlab.com/octospacc/WinDog.git
synced 2025-06-05 22:09:20 +02:00
Add /api/v1/FileProxy, support for Telegram file linking
This commit is contained in:
@ -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),
|
||||
|
@ -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())),
|
||||
|
@ -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'''<!DOCTYPE html>
|
||||
|
||||
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))
|
||||
|
@ -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'<pre>{html_escape(text)}</pre>'})
|
||||
|
||||
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,
|
||||
|
40
WinDog.py
40
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:
|
||||
|
Reference in New Issue
Block a user