44
55import asyncio
66import logging
7- import time
7+ import sqlite3
8+ from collections .abc import Callable
9+ from contextlib import suppress
810from pathlib import PurePosixPath
911from urllib .parse import unquote , urlsplit
1012
11- from telethon import TelegramClient , errors , events , types
13+ from telethon import Button , TelegramClient , errors , events , types
1214from telethon .tl .custom import Message
1315
16+ from uploadbot .commands import Commands , expiry_text , file_buttons
1417from uploadbot .config import Settings
15- from uploadbot .transfer import TransferClient , TransferError , stream_telegram
18+ from uploadbot .errors import TransferError
19+ from uploadbot .history import HistoryStore
20+ from uploadbot .progress import ProgressMeter , human_size
21+ from uploadbot .service import UploadService
1622from uploadbot .urls import public_link
1723
1824logger = logging .getLogger (__name__ )
1925
2026
21- def human_size (size : int ) -> str :
22- value = float (size )
23- for unit in ("B" , "KiB" , "MiB" , "GiB" ):
24- if value < 1024 or unit == "GiB" :
25- return f"{ value :.1f} { unit } "
26- value /= 1024
27- raise AssertionError ("unreachable" )
28-
29-
3027class UploadBot :
3128 def __init__ (
32- self , client : TelegramClient , transfer : TransferClient , settings : Settings
29+ self ,
30+ client : TelegramClient ,
31+ service : UploadService ,
32+ settings : Settings ,
33+ history : HistoryStore ,
3334 ) -> None :
3435 self .client = client
35- self .transfer = transfer
36+ self .service = service
37+ self .history = history
3638 self .settings = settings
3739 self .username = ""
3840 self .jobs : dict [int , asyncio .Task [None ]] = {}
41+ self .commands = Commands (history , service .registry , settings , self .jobs )
3942
4043 async def handle (self , event : events .NewMessage .Event ) -> None :
4144 if not event .is_private or event .sender_id is None :
4245 return
46+ if getattr (event .message , "grouped_id" , None ):
47+ return # Album events own grouped media, avoiding duplicate uploads.
4348 user_id = event .sender_id
4449 if self .settings .allowed_user_ids and user_id not in self .settings .allowed_user_ids :
4550 await event .reply ("This bot is private." )
4651 return
4752 text = (event .raw_text or "" ).strip ()
4853 if text .startswith ("/" ):
49- await self .handle_command (event , text )
54+ try :
55+ await self .handle_command (event , text )
56+ except TransferError as exc :
57+ await event .reply (str (exc ), parse_mode = None )
58+ except (sqlite3 .Error , errors .RPCError , ValueError ) as exc :
59+ logger .warning ("Command failed (%s)" , type (exc ).__name__ )
60+ with suppress (errors .RPCError ):
61+ await event .reply ("Command failed. Please try again later." )
5062 return
5163
5264 # Link previews can expose message.file too; only uploaded media are files.
@@ -84,18 +96,57 @@ async def handle(self, event: events.NewMessage.Event) -> None:
8496 finally :
8597 self .jobs .pop (user_id , None )
8698
99+ async def handle_album (self , event : events .Album .Event ) -> None :
100+ if not event .is_private or event .sender_id is None :
101+ return
102+ user_id = event .sender_id
103+ if self .settings .allowed_user_ids and user_id not in self .settings .allowed_user_ids :
104+ await event .reply ("This bot is private." )
105+ return
106+ messages = event .messages
107+ if not messages or any (
108+ not m .file or not m .file .size or m .file .size > self .settings .max_file_size
109+ for m in messages
110+ ):
111+ await event .reply (
112+ "Every album file must be nonempty and at most "
113+ f"{ human_size (self .settings .max_file_size )} ."
114+ )
115+ return
116+ if user_id in self .jobs or len (self .jobs ) >= self .settings .max_transfers :
117+ await event .reply ("Transfer slots are busy. Use /cancel or try again shortly." )
118+ return
119+ task = asyncio .current_task ()
120+ if task is None :
121+ raise RuntimeError ("Albums must run in an asyncio task" )
122+ self .jobs [user_id ] = task
123+ try :
124+ await self .run_transfer (event , "" , is_upload = True , messages = messages )
125+ finally :
126+ self .jobs .pop (user_id , None )
127+
87128 async def handle_command (self , event : events .NewMessage .Event , text : str ) -> None :
88129 token = text .split (maxsplit = 1 )[0 ][1 :].lower ()
89130 command , _ , address = token .partition ("@" )
90131 if address and address != self .username .lower ():
91132 return
133+ parts = text .split (maxsplit = 1 )
134+ argument = parts [1 ] if len (parts ) > 1 else ""
135+ if await self .commands .handle (event , command , argument ):
136+ return
92137 if command in {"start" , "help" }:
93138 upload_host = urlsplit (self .settings .transfer_url ).hostname
94139 await event .reply (
95140 "UploadBot · files in, links out\n \n "
96141 f"Send a file to stream it to { upload_host } .\n "
97142 "Send a public HTTP(S) file link to receive it on Telegram.\n \n "
98143 f"Files up to { human_size (self .settings .max_file_size )} \n "
144+ "/files [search] · recent uploads\n "
145+ "/link ID · retrieve a link\n "
146+ "/delete ID · delete a hosted file\n "
147+ "/provider [NAME|auto] · choose a host\n "
148+ "/expiry [1-14] · retention for new files\n "
149+ "/encrypt [on|off] · encrypt new uploads\n "
99150 "/cancel to stop your current transfer\n "
100151 "/help to show this message\n \n "
101152 "Uploaded files are shared through a link. Retention depends on the server." ,
@@ -113,16 +164,34 @@ async def handle_command(self, event: events.NewMessage.Event, text: str) -> Non
113164 await event .reply ("Send a file or a public file link. Use /help for details." )
114165
115166 async def run_transfer (
116- self , event : events . NewMessage . Event , text : str , * , is_upload : bool
167+ self , event , text : str , * , is_upload : bool , messages : list [ Message ] | None = None
117168 ) -> None :
118169 status = None
170+
171+ def committed () -> None :
172+ nonlocal status
173+ status = None
174+
119175 try :
120176 status = await event .reply (
121- "Streaming file…" if is_upload else "Sending link to Telegram…"
177+ "Streaming file…" if is_upload else "Sending link to Telegram…" ,
178+ buttons = Button .inline ("Cancel" , b"cancel" ),
122179 )
123180 async with asyncio .timeout (self .settings .transfer_timeout ):
124181 if is_upload :
125- await self .upload_file (event .message , status )
182+ items = messages or [event .message ]
183+ for index , message in enumerate (items ):
184+ current = (
185+ status
186+ if index == 0
187+ else await event .reply (
188+ f"Streaming file { index + 1 } of { len (items )} …" ,
189+ buttons = Button .inline ("Cancel" , b"cancel" ),
190+ )
191+ )
192+ status = current
193+ await self .upload_file (message , current , event , on_uploaded = committed )
194+ status = None
126195 else :
127196 await self .send_url (event , text , status )
128197 except asyncio .CancelledError :
@@ -149,26 +218,82 @@ async def run_transfer(
149218 logger .error ("Unexpected transfer failure (%s)" , type (exc ).__name__ )
150219 await self .show_error (event , status , "Transfer failed. Please try again later." )
151220
152- async def upload_file (self , message : Message , status : Message ) -> None :
153- last_update = time .monotonic ()
221+ async def upload_file (
222+ self ,
223+ message : Message ,
224+ status : Message ,
225+ event ,
226+ * ,
227+ on_uploaded : Callable [[], None ] | None = None ,
228+ ) -> None :
229+ meter = ProgressMeter ()
154230
155231 async def progress (sent : int , total : int ) -> None :
156- nonlocal last_update
157- now = time . monotonic ()
158- if now - last_update >= 4 :
159- last_update = now
160- await self .edit_status (
161- status , f"Streaming… { sent / total :.0% } · { human_size ( sent ) } "
162- )
232+ text = meter . update ( sent , total )
233+ if text :
234+ await self . edit_status ( status , text , buttons = Button . inline ( "Cancel" , b"cancel" ))
235+
236+ provider , options = await self .history . preferences (
237+ event . sender_id , self . service . registry . default
238+ )
163239
164- link = await stream_telegram (self .client , message , self .transfer , progress )
240+ async def on_key (key : str ) -> None :
241+ await event .reply (
242+ f"Decryption key for { message .file .name or 'this file' } \n \n { key } \n \n "
243+ "Keep this message. Use uploadbot-decrypt after downloading the .ubenc file. "
244+ "The key is not saved in history. Upload will start now." ,
245+ parse_mode = None ,
246+ link_preview = False ,
247+ )
248+
249+ completed = await self .service .upload (message , provider , options , progress , on_key = on_key )
250+ if on_uploaded :
251+ on_uploaded ()
252+ receipt = asyncio .create_task (self .finish_upload (event , status , completed .result ))
253+ try :
254+ await asyncio .shield (receipt )
255+ except asyncio .CancelledError :
256+ while not receipt .done ():
257+ try :
258+ await asyncio .shield (receipt )
259+ except asyncio .CancelledError :
260+ continue
261+ except Exception :
262+ break
263+ if not receipt .cancelled ():
264+ receipt .exception ()
265+ raise
266+
267+ async def finish_upload (self , event , status : Message , result ) -> None :
268+ link = result .url
269+ try :
270+ record = await self .history .add (event .sender_id , result )
271+ except (sqlite3 .Error , ValueError ):
272+ record = None
165273 notice = ""
166274 if not public_link (link ):
167275 notice = (
168276 "This link is on a private network. Open it from the bot server "
169277 "or use an SSH tunnel.\n \n "
170278 )
171- await self .edit_status (status , f"Upload complete ✓\n \n { notice } { link } " )
279+ if record :
280+ text = f"Upload complete ✓ · { record .id [:8 ]} \n { expiry_text (record )} \n \n { notice } { link } "
281+ else :
282+ text = (
283+ f"Upload complete ✓\n History could not be saved. Keep this link.\n \n { notice } { link } "
284+ )
285+ try :
286+ await status .edit (
287+ text ,
288+ parse_mode = None ,
289+ link_preview = False ,
290+ buttons = file_buttons (record ) if record else None ,
291+ )
292+ except errors .RPCError :
293+ try :
294+ await status .edit (text , parse_mode = None , link_preview = False , buttons = None )
295+ except errors .RPCError :
296+ await event .reply (text , parse_mode = None , link_preview = False )
172297
173298 async def send_url (self , event : events .NewMessage .Event , url : str , status : Message ) -> None :
174299 # Telegram fetches the URL; the bot host never downloads it locally.
@@ -180,9 +305,9 @@ async def send_url(self, event: events.NewMessage.Event, url: str, status: Messa
180305 await self .edit_status (status , "Sent to Telegram ✓" )
181306
182307 @staticmethod
183- async def edit_status (status : Message , text : str ) -> None :
308+ async def edit_status (status : Message , text : str , * , buttons = None ) -> None :
184309 try :
185- await status .edit (text , parse_mode = None , link_preview = False )
310+ await status .edit (text , parse_mode = None , link_preview = False , buttons = buttons )
186311 except errors .RPCError :
187312 pass # Deleted status messages / edit rate limits must not interrupt an upload.
188313
0 commit comments