YTSync 0.0.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yt2jf/__init__.py +1 -0
- yt2jf/bot.py +436 -0
- yt2jf/config.py +80 -0
- yt2jf/exceptions.py +40 -0
- yt2jf/main.py +79 -0
- yt2jf/poll.py +59 -0
- yt2jf/pydantic_config.py +145 -0
- yt2jf/routes.py +166 -0
- yt2jf/settings.py +98 -0
- yt2jf/transfer.py +87 -0
- yt2jf/version.py +1 -0
- yt2jf/webhook.py +81 -0
- yt2jf/word_match.py +52 -0
- yt2jf/youtube.py +279 -0
- ytsync-0.0.0a0.dist-info/LICENSE +21 -0
- ytsync-0.0.0a0.dist-info/METADATA +63 -0
- ytsync-0.0.0a0.dist-info/RECORD +19 -0
- ytsync-0.0.0a0.dist-info/WHEEL +5 -0
- ytsync-0.0.0a0.dist-info/top_level.txt +1 -0
yt2jf/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .main import start # noqa: F401
|
yt2jf/bot.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
# noinspection PyUnresolvedReferences
|
|
2
|
+
"""Module for TelegramAPI.
|
|
3
|
+
|
|
4
|
+
>>> Bot
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import secrets
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Dict, List
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from yt_dlp.utils import DownloadError
|
|
18
|
+
|
|
19
|
+
from yt2jf.config import env
|
|
20
|
+
from yt2jf.exceptions import BotInUse, BotTokenInvalid, BotWebhookConflict
|
|
21
|
+
from yt2jf.settings import Audio, Chat, Document, PhotoFragment, Text, Video, Voice
|
|
22
|
+
from yt2jf.word_match import word_match
|
|
23
|
+
from yt2jf.youtube import queue_download
|
|
24
|
+
|
|
25
|
+
BASE_URL = f"https://api.telegram.org/bot{env.bot_token}"
|
|
26
|
+
LOGGER = logging.getLogger("uvicorn.default")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RequestMethods(StrEnum):
|
|
30
|
+
"""Allowed request methods.
|
|
31
|
+
|
|
32
|
+
>>> RequestMethods
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
GET = "GET"
|
|
37
|
+
POST = "POST"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def intro() -> str:
|
|
41
|
+
"""Returns a welcome message as a string.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
str:
|
|
45
|
+
"""
|
|
46
|
+
return (
|
|
47
|
+
"\nTo start, send a link to YT music playlist in the following format:\n\n"
|
|
48
|
+
"- /id: <playlist id>\n- /url: <playlist url>\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make_request(
|
|
53
|
+
url: str,
|
|
54
|
+
payload: dict,
|
|
55
|
+
files: dict = None,
|
|
56
|
+
method: RequestMethods = RequestMethods.POST,
|
|
57
|
+
) -> requests.Response:
|
|
58
|
+
"""Makes a post request with a ``connect timeout`` of 5 seconds and ``read timeout`` of 60.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
url: URL to submit the request.
|
|
62
|
+
payload: Payload received, to extract information from.
|
|
63
|
+
files: Take filename as an optional argument.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Response:
|
|
67
|
+
Response class.
|
|
68
|
+
"""
|
|
69
|
+
if method == RequestMethods.GET:
|
|
70
|
+
response = requests.get(url=url, data=payload, files=files, timeout=(2, 3))
|
|
71
|
+
elif method == RequestMethods.POST:
|
|
72
|
+
response = requests.post(url=url, data=payload, files=files, timeout=(2, 3))
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError("Invalid request method received: '%s'", method)
|
|
75
|
+
if not response.ok:
|
|
76
|
+
LOGGER.debug(payload)
|
|
77
|
+
LOGGER.debug(files)
|
|
78
|
+
LOGGER.warning("Called by: '%s'", sys._getframe(1).f_code.co_name) # noqa
|
|
79
|
+
LOGGER.error(response.json())
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reply_to(
|
|
84
|
+
chat: Chat,
|
|
85
|
+
response: str,
|
|
86
|
+
parse_mode: str | None = "markdown",
|
|
87
|
+
retry: bool = False,
|
|
88
|
+
) -> requests.Response:
|
|
89
|
+
"""Generates a payload to reply to a message received.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
chat: Required section of the payload as Chat object.
|
|
93
|
+
response: Message to be sent to the user.
|
|
94
|
+
parse_mode: Parse mode. Defaults to ``markdown``
|
|
95
|
+
retry: Retry reply in case reply failed because of parsing.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Response:
|
|
99
|
+
Response class.
|
|
100
|
+
"""
|
|
101
|
+
result = _make_request(
|
|
102
|
+
url=BASE_URL + "/sendMessage",
|
|
103
|
+
payload={
|
|
104
|
+
"chat_id": chat.id,
|
|
105
|
+
"reply_to_message_id": chat.message_id,
|
|
106
|
+
"text": response,
|
|
107
|
+
"parse_mode": parse_mode,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
# Retry with response as plain text
|
|
111
|
+
if result.status_code == 400 and parse_mode and not retry:
|
|
112
|
+
LOGGER.warning("Retrying response as plain text with no parsing")
|
|
113
|
+
reply_to(chat, response, None, True)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def send_message(
|
|
118
|
+
chat_id: int,
|
|
119
|
+
response: str,
|
|
120
|
+
parse_mode: str | None = "markdown",
|
|
121
|
+
retry: bool = False,
|
|
122
|
+
) -> requests.Response:
|
|
123
|
+
"""Generates a payload to reply to a message received.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
chat_id: Chat ID.
|
|
127
|
+
response: Message to be sent to the user.
|
|
128
|
+
parse_mode: Parse mode. Defaults to ``markdown``
|
|
129
|
+
retry: Retry reply in case reply failed because of parsing.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Response:
|
|
133
|
+
Response class.
|
|
134
|
+
"""
|
|
135
|
+
result = _make_request(
|
|
136
|
+
url=BASE_URL + "/sendMessage",
|
|
137
|
+
payload={"chat_id": chat_id, "text": response, "parse_mode": parse_mode},
|
|
138
|
+
)
|
|
139
|
+
# Retry with response as plain text
|
|
140
|
+
if result.status_code == 400 and parse_mode and not retry:
|
|
141
|
+
LOGGER.warning("Retrying response as plain text with no parsing")
|
|
142
|
+
send_message(chat_id=chat_id, response=response, parse_mode=None, retry=True)
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def poll_for_messages(offset: int) -> None | int:
|
|
147
|
+
"""Polls ``api.telegram.org`` for new messages.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
offset: Offset in messages to poll.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
BotInUse:
|
|
154
|
+
- When a new polling is initiated using the same token.
|
|
155
|
+
ConnectionError:
|
|
156
|
+
- If unable to connect to the endpoint.
|
|
157
|
+
|
|
158
|
+
See Also:
|
|
159
|
+
Swaps ``offset`` value during every iteration to avoid reprocessing messages.
|
|
160
|
+
"""
|
|
161
|
+
response = _make_request(
|
|
162
|
+
url=BASE_URL + "/getUpdates",
|
|
163
|
+
payload={"offset": offset, "timeout": 60},
|
|
164
|
+
method=RequestMethods.GET,
|
|
165
|
+
)
|
|
166
|
+
if response.ok:
|
|
167
|
+
results = response.json().get("result", [])
|
|
168
|
+
if not results:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
last_update_id = offset
|
|
172
|
+
for result in results:
|
|
173
|
+
if payload := result.get("message"):
|
|
174
|
+
process_request(payload)
|
|
175
|
+
else:
|
|
176
|
+
LOGGER.error("Received empty payload!!")
|
|
177
|
+
last_update_id = result["update_id"]
|
|
178
|
+
|
|
179
|
+
return last_update_id + 1
|
|
180
|
+
|
|
181
|
+
# Handle errors
|
|
182
|
+
error_data = response.json()
|
|
183
|
+
err_desc = error_data.get("description", "")
|
|
184
|
+
|
|
185
|
+
if response.status_code == 409:
|
|
186
|
+
if "webhook" in err_desc.lower():
|
|
187
|
+
raise BotWebhookConflict(err_desc)
|
|
188
|
+
raise BotInUse(err_desc)
|
|
189
|
+
if response.status_code == 401:
|
|
190
|
+
raise BotTokenInvalid(error_data)
|
|
191
|
+
raise ConnectionError(error_data)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def process_request(payload: Dict[str, int | dict]) -> None:
|
|
195
|
+
"""Processes the request via Telegram messages.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
payload: Payload as received.
|
|
199
|
+
"""
|
|
200
|
+
LOGGER.debug(payload)
|
|
201
|
+
chat = Chat(**{**payload, **payload["chat"], **payload["from"]})
|
|
202
|
+
if not authenticate(chat):
|
|
203
|
+
LOGGER.warning(payload)
|
|
204
|
+
return
|
|
205
|
+
if not verify_timeout(chat):
|
|
206
|
+
LOGGER.warning(payload)
|
|
207
|
+
return
|
|
208
|
+
if payload.get("text"):
|
|
209
|
+
chat.message_type = "text"
|
|
210
|
+
process_text(chat, Text(**payload))
|
|
211
|
+
elif payload.get("voice"):
|
|
212
|
+
chat.message_type = "voice"
|
|
213
|
+
process_voice(chat, Voice(**payload["voice"]))
|
|
214
|
+
elif payload.get("document"):
|
|
215
|
+
chat.message_type = "document"
|
|
216
|
+
process_document(chat, Document(**payload["document"]))
|
|
217
|
+
elif payload.get("video"):
|
|
218
|
+
chat.message_type = "video"
|
|
219
|
+
process_video(chat, Video(**payload["video"]))
|
|
220
|
+
elif payload.get("audio"):
|
|
221
|
+
chat.message_type = "audio"
|
|
222
|
+
process_audio(chat, Audio(**payload["audio"]))
|
|
223
|
+
elif payload.get("photo"):
|
|
224
|
+
# Matches for compressed images
|
|
225
|
+
chat.message_type = "photo"
|
|
226
|
+
process_photo(chat, [PhotoFragment(**d) for d in payload["photo"]])
|
|
227
|
+
else:
|
|
228
|
+
reply_to(chat, "Payload type is not allowed.")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def username_is_valid(username: str) -> bool:
|
|
232
|
+
"""Compares username and returns True if username is allowed."""
|
|
233
|
+
for user in env.bot_users:
|
|
234
|
+
if secrets.compare_digest(user, username):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def authenticate(chat: Chat) -> bool:
|
|
240
|
+
"""Authenticates the user with ``userId`` and ``userName``.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
chat: Required section of the payload as Chat object.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
bool:
|
|
247
|
+
Returns a boolean to indicate whether the user is authenticated.
|
|
248
|
+
"""
|
|
249
|
+
if chat.is_bot:
|
|
250
|
+
LOGGER.error("Bot request from %s", chat.username)
|
|
251
|
+
send_message(
|
|
252
|
+
chat_id=chat.id,
|
|
253
|
+
response=f"Sorry {chat.first_name}! I can't process requests from bots.",
|
|
254
|
+
)
|
|
255
|
+
return False
|
|
256
|
+
if chat.id not in env.bot_chat_ids or not username_is_valid(username=chat.username):
|
|
257
|
+
LOGGER.error("Unauthorized chatID [%d] or userName [%s]", chat.id, chat.username)
|
|
258
|
+
send_message(chat_id=chat.id, response=f"401 Unauthorized user: ({chat.username})")
|
|
259
|
+
return False
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def verify_timeout(chat: Chat) -> bool:
|
|
264
|
+
"""Verifies whether the message was received in the past 60 seconds.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
chat: Required section of the payload as Chat object.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
bool:
|
|
271
|
+
True or False flag to indicate if the request timed out.
|
|
272
|
+
"""
|
|
273
|
+
if int(time.time()) - chat.date < 60:
|
|
274
|
+
return True
|
|
275
|
+
request_time = time.strftime("%m-%d-%Y %H:%M:%S", time.localtime(chat.date))
|
|
276
|
+
LOGGER.warning("Request timed out [%s] for %s", request_time, chat.username)
|
|
277
|
+
reply_to(
|
|
278
|
+
chat,
|
|
279
|
+
f"Request timed out\nRequested: {request_time}\n"
|
|
280
|
+
f"Processed: {time.strftime('%m-%d-%Y %H:%M:%S', time.localtime(time.time()))}",
|
|
281
|
+
)
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def process_photo(chat: Chat, data_class: List[PhotoFragment]) -> None:
|
|
286
|
+
"""Processes a photo input.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
chat: Required section of the payload as Chat object.
|
|
290
|
+
data_class: Required section of the payload as Voice object.
|
|
291
|
+
"""
|
|
292
|
+
LOGGER.info(data_class)
|
|
293
|
+
reply_to(
|
|
294
|
+
chat,
|
|
295
|
+
"Image fragments are not supported. If you're sending a compressed image, "
|
|
296
|
+
"please try sending it without compression.",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def process_audio(chat: Chat, data_class: Audio) -> None:
|
|
301
|
+
"""Processes an audio input.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
chat: Required section of the payload as Chat object.
|
|
305
|
+
data_class: Required section of the payload as Voice object.
|
|
306
|
+
"""
|
|
307
|
+
process_document(chat, data_class)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def process_video(chat: Chat, data_class: Video) -> None:
|
|
311
|
+
"""Processes a video input.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
chat: Required section of the payload as Chat object.
|
|
315
|
+
data_class: Required section of the payload as Voice object.
|
|
316
|
+
"""
|
|
317
|
+
process_document(chat, data_class)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def process_voice(chat: Chat, data_class: Voice) -> None:
|
|
321
|
+
"""Processes the audio file in payload received after checking for authentication.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
chat: Required section of the payload as Chat object.
|
|
325
|
+
data_class: Required section of the payload as Voice object.
|
|
326
|
+
"""
|
|
327
|
+
reply_to(chat, "Audio inputs are not supported at the moment. Please try text input.")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def process_document(chat: Chat, data_class: Document | Audio | Video) -> None:
|
|
331
|
+
"""Processes the document in payload received after checking for authentication.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
chat: Required section of the payload as Chat object.
|
|
335
|
+
data_class: Required section of the payload as Document object.
|
|
336
|
+
"""
|
|
337
|
+
reply_to(chat, "Document inputs are not supported at the moment. Please try text input.")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def process_text(chat: Chat, data_class: Text) -> None:
|
|
341
|
+
"""Processes the text in payload received after checking for authentication.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
chat: Required section of the payload as Chat object.
|
|
345
|
+
data_class: Required section of the payload as Text object.
|
|
346
|
+
"""
|
|
347
|
+
if data_class.text:
|
|
348
|
+
data_class.text = data_class.text.strip()
|
|
349
|
+
else:
|
|
350
|
+
send_message(chat_id=chat.id, response="Un-processable payload")
|
|
351
|
+
return
|
|
352
|
+
data_class.text = data_class.text.replace("override", "").replace("OVERRIDE", "")
|
|
353
|
+
text_lower = data_class.text.lower().lstrip("/")
|
|
354
|
+
if word_match(
|
|
355
|
+
phrase=text_lower,
|
|
356
|
+
match_list=(
|
|
357
|
+
"hey",
|
|
358
|
+
"hola",
|
|
359
|
+
"what's up",
|
|
360
|
+
"ssup",
|
|
361
|
+
"whats up",
|
|
362
|
+
"hello",
|
|
363
|
+
"hi",
|
|
364
|
+
"howdy",
|
|
365
|
+
"hey",
|
|
366
|
+
"chao",
|
|
367
|
+
"hiya",
|
|
368
|
+
"aloha",
|
|
369
|
+
),
|
|
370
|
+
strict=True,
|
|
371
|
+
):
|
|
372
|
+
reply_to(
|
|
373
|
+
chat,
|
|
374
|
+
intro(),
|
|
375
|
+
)
|
|
376
|
+
return
|
|
377
|
+
if text_lower == "start":
|
|
378
|
+
send_message(chat.id, intro())
|
|
379
|
+
return
|
|
380
|
+
if text_lower == "help":
|
|
381
|
+
send_message(
|
|
382
|
+
chat_id=chat.id,
|
|
383
|
+
response="Use '/id' or '/url' followed by the identifier.",
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
if text_lower == "test":
|
|
387
|
+
reply_to(chat, f"Test message received at - {datetime.now().strftime('%c')}")
|
|
388
|
+
return
|
|
389
|
+
executor(data_class.text, chat)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def executor(command: str, chat: Chat) -> None:
|
|
393
|
+
"""Executes the command via offline communicator.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
command: Command to be executed.
|
|
397
|
+
chat: Required section of the payload as Chat object.
|
|
398
|
+
"""
|
|
399
|
+
LOGGER.info("Request: %s", command)
|
|
400
|
+
# TODO: Replace with reply_to
|
|
401
|
+
kwargs = dict(chat_id=chat.id, callback=send_message)
|
|
402
|
+
if command.startswith("/id"):
|
|
403
|
+
if playlist_id := command.replace("/id", "").strip():
|
|
404
|
+
kwargs["playlist_id"] = playlist_id
|
|
405
|
+
else:
|
|
406
|
+
reply_to(chat, "Invalid entry, a playlist id is required followed by /id")
|
|
407
|
+
return
|
|
408
|
+
elif command.startswith("/url"):
|
|
409
|
+
if playlist_url := command.replace("/url", "").strip():
|
|
410
|
+
kwargs["playlist_url"] = playlist_url
|
|
411
|
+
else:
|
|
412
|
+
reply_to(chat, "Invalid entry, a playlist url is required followed by /url")
|
|
413
|
+
return
|
|
414
|
+
else:
|
|
415
|
+
process_response(
|
|
416
|
+
f"Invalid command received: {command}\n\nEither use '/id' or '/url' followed by the identifier.",
|
|
417
|
+
chat,
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
try:
|
|
421
|
+
name = queue_download(**kwargs)
|
|
422
|
+
response = f"Download queued for {name!r}"
|
|
423
|
+
except (ValueError, AssertionError, DownloadError) as error:
|
|
424
|
+
response = error.__str__()
|
|
425
|
+
LOGGER.info("Response: %s", response)
|
|
426
|
+
process_response(response, chat)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def process_response(response: str, chat: Chat) -> None:
|
|
430
|
+
"""Processes the response via Telegram API.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
response: Response from yt2jf.
|
|
434
|
+
chat: Required section of the payload as Chat object.
|
|
435
|
+
"""
|
|
436
|
+
send_message(chat.id, response, None)
|
yt2jf/config.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
import pathlib
|
|
4
|
+
import socket
|
|
5
|
+
import warnings
|
|
6
|
+
from ipaddress import IPv4Address
|
|
7
|
+
from multiprocessing import current_process
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
from pydantic import (
|
|
11
|
+
DirectoryPath,
|
|
12
|
+
Field,
|
|
13
|
+
FilePath,
|
|
14
|
+
HttpUrl,
|
|
15
|
+
NewPath,
|
|
16
|
+
PositiveFloat,
|
|
17
|
+
PositiveInt,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from yt2jf.pydantic_config import PydanticEnvConfig
|
|
21
|
+
|
|
22
|
+
SECRETS_PATH = os.environ.get("SECRETS_PATH") or os.environ.get("secrets_path") or ".env"
|
|
23
|
+
LOGICAL_CORES = os.cpu_count() or 2
|
|
24
|
+
PHYSICAL_CORES = math.ceil(LOGICAL_CORES / 2)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EnvConfig(PydanticEnvConfig):
|
|
28
|
+
"""Configuration values for the project.
|
|
29
|
+
|
|
30
|
+
>>> EnvConfig
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
host: str = socket.gethostbyname("localhost")
|
|
35
|
+
port: PositiveInt = 4483
|
|
36
|
+
log_config: FilePath | Dict[str, Any] | None = None
|
|
37
|
+
|
|
38
|
+
# Applies to both rsync and telegram polling
|
|
39
|
+
max_retries: PositiveInt | PositiveFloat = Field(10, le=30, ge=1)
|
|
40
|
+
backoff_factor: PositiveInt | PositiveFloat = Field(3, le=10, ge=1)
|
|
41
|
+
|
|
42
|
+
# Concurrency
|
|
43
|
+
max_listeners: PositiveInt = Field(PHYSICAL_CORES, le=LOGICAL_CORES, ge=1)
|
|
44
|
+
max_transfers: PositiveInt = Field(LOGICAL_CORES, le=LOGICAL_CORES * 2, ge=1)
|
|
45
|
+
|
|
46
|
+
# Data
|
|
47
|
+
data_dir: NewPath | DirectoryPath = pathlib.Path("data")
|
|
48
|
+
|
|
49
|
+
# Telegram config
|
|
50
|
+
bot_token: str
|
|
51
|
+
bot_chat_ids: List[int]
|
|
52
|
+
bot_users: List[str]
|
|
53
|
+
poll_interval: PositiveInt | PositiveFloat = Field(2, le=5, ge=1)
|
|
54
|
+
|
|
55
|
+
# Remote config
|
|
56
|
+
remote_host: str | None = None
|
|
57
|
+
remote_user: str | None = None
|
|
58
|
+
remote_path: str | None = None
|
|
59
|
+
delete_after_sync: bool = True
|
|
60
|
+
|
|
61
|
+
# Telegram Webhook specific
|
|
62
|
+
bot_webhook: HttpUrl | None = None
|
|
63
|
+
bot_webhook_ip: IPv4Address | None = None
|
|
64
|
+
bot_endpoint: str = Field("/telegram-webhook", pattern=r"^\/")
|
|
65
|
+
bot_secret: str | None = Field(None, pattern="^[A-Za-z0-9_-]{1,256}$")
|
|
66
|
+
bot_certificate: FilePath | None = None
|
|
67
|
+
|
|
68
|
+
class Config:
|
|
69
|
+
"""Environment variables configuration."""
|
|
70
|
+
|
|
71
|
+
vault_table = "yt2jf"
|
|
72
|
+
env_file = SECRETS_PATH
|
|
73
|
+
extra = "ignore"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
env = EnvConfig()
|
|
77
|
+
|
|
78
|
+
if not all((env.remote_host, env.remote_path, env.remote_user)) and current_process().name == "MainProcess":
|
|
79
|
+
warnings.warn("No remote connections have been setup, all downloaded media will be stored locally.")
|
|
80
|
+
env.data_dir.mkdir(exist_ok=True)
|
yt2jf/exceptions.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
EgressErrors = (
|
|
4
|
+
ConnectionError,
|
|
5
|
+
TimeoutError,
|
|
6
|
+
requests.RequestException,
|
|
7
|
+
requests.Timeout,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BotError(Exception):
|
|
12
|
+
"""Custom base exception for Telegram Bot.
|
|
13
|
+
|
|
14
|
+
>>> BotError
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BotWebhookConflict(BotError):
|
|
20
|
+
"""Error for conflict with webhook and getUpdates API call.
|
|
21
|
+
|
|
22
|
+
>>> BotWebhookConflict
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BotInUse(BotError):
|
|
28
|
+
"""Error indicate bot token is being used else where.
|
|
29
|
+
|
|
30
|
+
>>> BotInUse
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BotTokenInvalid(BotError):
|
|
36
|
+
"""Error indicate bot token is invalid.
|
|
37
|
+
|
|
38
|
+
>>> BotTokenInvalid
|
|
39
|
+
|
|
40
|
+
"""
|
yt2jf/main.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import pathlib
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.routing import APIRoute
|
|
9
|
+
|
|
10
|
+
from yt2jf.config import env
|
|
11
|
+
from yt2jf.poll import run_polling, shutdown_event
|
|
12
|
+
from yt2jf.routes import (
|
|
13
|
+
ACTIVE_TASKS,
|
|
14
|
+
api_delete_webhook,
|
|
15
|
+
api_get_webhook,
|
|
16
|
+
api_set_webhook,
|
|
17
|
+
telegram_webhook,
|
|
18
|
+
)
|
|
19
|
+
from yt2jf.version import __version__
|
|
20
|
+
|
|
21
|
+
LOGGER = logging.getLogger("uvicorn.default")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@asynccontextmanager
|
|
25
|
+
async def lifespan(_: FastAPI):
|
|
26
|
+
"""Simple startup function to add anything that has to be triggered when Jarvis API starts up."""
|
|
27
|
+
# noinspection HttpUrlsUsage
|
|
28
|
+
LOGGER.info("Hosting at http://%s:%s", env.host, env.port)
|
|
29
|
+
bg_task = None
|
|
30
|
+
if not env.bot_webhook:
|
|
31
|
+
LOGGER.info("Polling for incoming messages...")
|
|
32
|
+
bg_task = asyncio.create_task(run_polling())
|
|
33
|
+
ACTIVE_TASKS["poll"] = bg_task
|
|
34
|
+
yield
|
|
35
|
+
if bg_task:
|
|
36
|
+
bg_task.cancel()
|
|
37
|
+
shutdown_event()
|
|
38
|
+
LOGGER.info("Shutting down API server.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
routes = [
|
|
42
|
+
APIRoute(
|
|
43
|
+
endpoint=telegram_webhook,
|
|
44
|
+
methods=["POST"],
|
|
45
|
+
path=env.bot_endpoint, # No enum
|
|
46
|
+
include_in_schema=False,
|
|
47
|
+
),
|
|
48
|
+
APIRoute(
|
|
49
|
+
endpoint=api_get_webhook,
|
|
50
|
+
methods=["GET"],
|
|
51
|
+
path="/get-webhook",
|
|
52
|
+
),
|
|
53
|
+
APIRoute(
|
|
54
|
+
endpoint=api_set_webhook,
|
|
55
|
+
methods=["POST"],
|
|
56
|
+
path="/set-webhook",
|
|
57
|
+
),
|
|
58
|
+
APIRoute(
|
|
59
|
+
endpoint=api_delete_webhook,
|
|
60
|
+
methods=["DELETE"],
|
|
61
|
+
path="/delete-webhook",
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
app = FastAPI(title="YT2JF", version=__version__, lifespan=lifespan, routes=routes)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def start():
|
|
69
|
+
"""Start the Jarvis API server using Uvicorn."""
|
|
70
|
+
module_name = pathlib.Path(__file__)
|
|
71
|
+
kwargs = dict(
|
|
72
|
+
host=env.host,
|
|
73
|
+
port=env.port,
|
|
74
|
+
app=f"{module_name.parent.stem}.main:app",
|
|
75
|
+
workers=1,
|
|
76
|
+
)
|
|
77
|
+
if env.log_config:
|
|
78
|
+
kwargs["log_config"] = env.log_config
|
|
79
|
+
uvicorn.run(**kwargs)
|
yt2jf/poll.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import requests.exceptions
|
|
5
|
+
|
|
6
|
+
from yt2jf.bot import poll_for_messages
|
|
7
|
+
from yt2jf.config import env
|
|
8
|
+
from yt2jf.exceptions import BotInUse, BotTokenInvalid, BotWebhookConflict, EgressErrors
|
|
9
|
+
from yt2jf.youtube import controllers, process_pool
|
|
10
|
+
|
|
11
|
+
LOGGER = logging.getLogger("uvicorn.default")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def shutdown_event():
|
|
15
|
+
"""Shuts down all the threads and gracefully terminates the processes."""
|
|
16
|
+
process_pool.shutdown(wait=True)
|
|
17
|
+
for controller in controllers:
|
|
18
|
+
LOGGER.info("Shutting down controller for: %s", controller.name)
|
|
19
|
+
try:
|
|
20
|
+
result = controller.future.result()
|
|
21
|
+
except Exception as exc:
|
|
22
|
+
LOGGER.error("Controller failed for %s: %s", controller.name, exc)
|
|
23
|
+
else:
|
|
24
|
+
LOGGER.info("Controller completed for %s: %s", controller.name, result)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def run_polling():
|
|
28
|
+
"""Starts up all the threads and gracefully terminates the processes."""
|
|
29
|
+
offset = 0
|
|
30
|
+
failed_connections = 0
|
|
31
|
+
while True:
|
|
32
|
+
try:
|
|
33
|
+
await asyncio.sleep(env.poll_interval)
|
|
34
|
+
if offset_id := poll_for_messages(offset):
|
|
35
|
+
offset = offset_id
|
|
36
|
+
except EgressErrors as error:
|
|
37
|
+
if isinstance(error, requests.exceptions.ReadTimeout):
|
|
38
|
+
continue
|
|
39
|
+
LOGGER.error(error)
|
|
40
|
+
failed_connections += 1
|
|
41
|
+
if failed_connections > env.max_retries:
|
|
42
|
+
LOGGER.critical("ATTENTION::Couldn't recover from connection error. Restarting current process.")
|
|
43
|
+
delay = failed_connections * env.backoff_factor
|
|
44
|
+
LOGGER.info("Restarting in %d seconds.", delay)
|
|
45
|
+
await asyncio.sleep(delay) # Simple backoff wait
|
|
46
|
+
except (
|
|
47
|
+
asyncio.CancelledError,
|
|
48
|
+
BotWebhookConflict,
|
|
49
|
+
BotInUse,
|
|
50
|
+
BotTokenInvalid,
|
|
51
|
+
KeyboardInterrupt,
|
|
52
|
+
Exception,
|
|
53
|
+
) as error:
|
|
54
|
+
if isinstance(error, asyncio.CancelledError):
|
|
55
|
+
LOGGER.info("Shutting down all threads and gracefully terminated.")
|
|
56
|
+
else:
|
|
57
|
+
LOGGER.error(error)
|
|
58
|
+
shutdown_event()
|
|
59
|
+
break
|