Skip to content

Commit ac83d3e

Browse files
committed
Wire streaming features into the Telegram bot
1 parent 8280996 commit ac83d3e

12 files changed

Lines changed: 620 additions & 52 deletions

File tree

‎.env.example‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ API_ID=
33
API_HASH=
44
BOT_TOKEN=
55

6-
# File2Share accepts streaming PUT uploads up to 95 MB per request.
6+
# Large files use File2Share numbered parts. Telegram's own limits also apply.
77
TRANSFER_URL=https://file2share.us
8-
MAX_FILE_SIZE=95000000
8+
MAX_FILE_SIZE=2000000000
99
MAX_CONCURRENT_TRANSFERS=3
1010
TRANSFER_TIMEOUT=1800
1111

1212
# Empty allows all users. Set your numeric Telegram ID for a private bot.
1313
ALLOWED_USER_IDS=
1414
SESSION_PATH=data/uploadbot
15+
16+
# Optional JSON profiles for put, file2share or webdav providers. See docs/setup.md.
17+
UPLOAD_PROVIDERS=[]

‎src/uploadbot/app.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
from uploadbot.bot import UploadBot
1414
from uploadbot.config import Settings
15-
from uploadbot.transfer import TransferClient
15+
from uploadbot.history import HistoryStore
16+
from uploadbot.providers import Provider, ProviderRegistry
17+
from uploadbot.service import UploadService
1618

1719

1820
async def run(settings: Settings) -> None:
@@ -29,9 +31,19 @@ async def run(settings: Settings) -> None:
2931
sock_read=60,
3032
)
3133
async with aiohttp.ClientSession(timeout=timeout) as session:
32-
transfer = TransferClient(session, settings.transfer_url, settings.max_file_size)
33-
bot = UploadBot(client, transfer, settings)
34+
registry = ProviderRegistry(
35+
[
36+
Provider(session, config, settings.max_file_size)
37+
for config in settings.provider_configs()
38+
]
39+
)
40+
history = await HistoryStore.open(
41+
settings.session_path.with_name(settings.session_path.name + ".history.sqlite3")
42+
)
43+
bot = UploadBot(client, UploadService(client, registry), settings, history)
3444
client.add_event_handler(bot.handle, events.NewMessage(incoming=True))
45+
client.add_event_handler(bot.handle_album, events.Album())
46+
client.add_event_handler(bot.commands.callback, events.CallbackQuery())
3547
try:
3648
await client.start(bot_token=settings.bot_token)
3749
me = await client.get_me()

‎src/uploadbot/bot.py‎

Lines changed: 156 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,49 +4,61 @@
44

55
import asyncio
66
import logging
7-
import time
7+
import sqlite3
8+
from collections.abc import Callable
9+
from contextlib import suppress
810
from pathlib import PurePosixPath
911
from urllib.parse import unquote, urlsplit
1012

11-
from telethon import TelegramClient, errors, events, types
13+
from telethon import Button, TelegramClient, errors, events, types
1214
from telethon.tl.custom import Message
1315

16+
from uploadbot.commands import Commands, expiry_text, file_buttons
1417
from 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
1622
from uploadbot.urls import public_link
1723

1824
logger = 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-
3027
class 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 ✓\nHistory 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

‎src/uploadbot/commands.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""Owner-scoped history, preferences, and inline file actions."""
22

33
import asyncio
4+
import logging
45
import sqlite3
6+
from contextlib import suppress
57
from dataclasses import replace
68
from datetime import UTC, datetime
79
from urllib.parse import urlencode
810

9-
from telethon import Button
11+
from telethon import Button, errors
1012

1113
from uploadbot.config import Settings
1214
from uploadbot.errors import TransferError
@@ -16,6 +18,8 @@
1618
from uploadbot.providers import ProviderRegistry
1719
from uploadbot.urls import public_link
1820

21+
logger = logging.getLogger(__name__)
22+
1923

2024
def file_buttons(record: StoredUpload):
2125
buttons = []
@@ -142,6 +146,14 @@ async def delete(self, user_id: int, record: StoredUpload) -> None:
142146
) from None
143147

144148
async def callback(self, event) -> None:
149+
try:
150+
await self._callback(event)
151+
except (sqlite3.Error, errors.RPCError, ValueError) as exc:
152+
logger.warning("File action failed (%s)", type(exc).__name__)
153+
with suppress(errors.RPCError):
154+
await event.answer("Action failed. Please try again later.", alert=True)
155+
156+
async def _callback(self, event) -> None:
145157
user_id = event.sender_id
146158
if not event.is_private or user_id is None or event.chat_id != user_id:
147159
await event.answer("Use these buttons in your private chat.", alert=True)

0 commit comments

Comments
 (0)