Add /api/v1/FileProxy, support for Telegram file linking

This commit is contained in:
2025-01-22 18:10:56 +01:00
parent 90fbf5033c
commit dc8e531079
5 changed files with 137 additions and 41 deletions

View File

@ -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),

View File

@ -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())),

View File

@ -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))

View File

@ -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,

View File

@ -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 ''}&timestamp={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: