warp-beacon 1.2.5__py3-none-any.whl → 2.0.0__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.
@@ -0,0 +1,156 @@
1
+ from pyrogram import Client
2
+ from pyrogram.types import Message
3
+ from pyrogram.enums import ChatType, ParseMode
4
+ from pyrogram.types import Chat, BotCommand
5
+
6
+ from urlextract import URLExtract
7
+
8
+ from warp_beacon.storage import Storage
9
+ from warp_beacon.telegram.utils import Utils
10
+ from warp_beacon.jobs.download_job import DownloadJob
11
+ from warp_beacon.jobs.upload_job import UploadJob
12
+ from warp_beacon.jobs import Origin
13
+ from warp_beacon.jobs.types import JobType
14
+
15
+ import logging
16
+
17
+ class Handlers(object):
18
+ storage = None
19
+ bot = None
20
+
21
+ def __init__(self, bot: "Bot") -> None:
22
+ self.bot = bot
23
+ self.storage = bot.storage
24
+
25
+ async def help(self, client: Client, message: Message) -> None:
26
+ """Send a message when the command /help is issued."""
27
+ await self.bot.send_text(text="Send me a link to remote media", reply_id=message.id, chat_id=message.chat.id)
28
+
29
+ async def random(self, client: Client, message: Message) -> None:
30
+ d = self.storage.get_random()
31
+ if not d:
32
+ await message.reply_text("No random content yet. Try to send link first.")
33
+ return
34
+ await self.bot.upload_job(UploadJob(tg_file_id=d["tg_file_id"], media_type=d["media_type"], message_id=message.id))
35
+
36
+ async def start(self, client: Client, message: Message) -> None:
37
+ bot_name = await self.bot.client.get_me()
38
+ await self.bot.client.set_bot_commands([
39
+ BotCommand("start", "Start bot"),
40
+ BotCommand("help", "Show help message"),
41
+ BotCommand("random", "Get random media")
42
+ ])
43
+ await message.reply_text(
44
+ parse_mode=ParseMode.MARKDOWN,
45
+ text=f"Welcome to @{bot_name.username}!\n"
46
+ "Send link to external social network with content and I'll reply to it.\n"
47
+ "Currently supported: Instagram, YouTube Shorts and YouTube Music."
48
+ )
49
+
50
+ async def handler(self, client: Client, message: Message) -> None:
51
+ chat = message.chat
52
+ effective_message_id = message.id
53
+ extractor = URLExtract()
54
+ urls = extractor.find_urls(message.text)
55
+
56
+ reply_text = "Wut?"
57
+ if not urls:
58
+ reply_text = "Your message should contains URLs"
59
+ else:
60
+ for url in urls:
61
+ origin = Utils.extract_origin(url)
62
+ if origin is Origin.YOUTU_BE:
63
+ url = Utils.extract_youtu_be_link(url)
64
+ if not url:
65
+ raise ValueError("Failed to extract youtu.be link")
66
+ origin = Origin.YOUTUBE
67
+ if origin is Origin.UNKNOWN:
68
+ logging.info("Only Instagram, YouTube Shorts and YouTube Music are now supported. Skipping.")
69
+ continue
70
+ entities, tg_file_ids = [], []
71
+ uniq_id = Storage.compute_uniq(url)
72
+ try:
73
+ entities = self.storage.db_lookup_id(uniq_id)
74
+ except Exception as e:
75
+ logging.error("Failed to search link in DB!")
76
+ logging.exception(e)
77
+ if entities:
78
+ tg_file_ids = [i["tg_file_id"] for i in entities]
79
+ logging.info("URL '%s' is found in DB. Sending with tg_file_ids = '%s'", url, str(tg_file_ids))
80
+ ent_len = len(entities)
81
+ if ent_len > 1:
82
+ await self.bot.upload_job(
83
+ UploadJob(
84
+ tg_file_id=",".join(tg_file_ids),
85
+ message_id=effective_message_id,
86
+ media_type=JobType.COLLECTION,
87
+ chat_id=chat.id
88
+ )
89
+ )
90
+ elif ent_len:
91
+ logging.info(entities[0])
92
+ media_type = JobType[entities[0]["media_type"].upper()]
93
+ await self.bot.upload_job(
94
+ UploadJob(
95
+ tg_file_id=tg_file_ids.pop(),
96
+ message_id=effective_message_id,
97
+ media_type=media_type,
98
+ chat_id=chat.id
99
+ )
100
+ )
101
+ else:
102
+ async def upload_wrapper(job: UploadJob) -> None:
103
+ try:
104
+ if job.job_failed and job.job_failed_msg:
105
+ if job.placeholder_message_id:
106
+ await self.bot.placeholder.remove(chat.id, job.placeholder_message_id)
107
+ return await self.bot.send_text(chat_id=chat.id, text=job.job_failed_msg, reply_id=job.message_id)
108
+ if job.job_warning and job.job_warning_msg:
109
+ return await self.bot.placeholder.update_text(chat.id, job.placeholder_message_id, job.job_warning_msg)
110
+ tg_file_ids = await self.bot.upload_job(job)
111
+ if tg_file_ids:
112
+ if job.media_type == JobType.COLLECTION and job.save_items:
113
+ for chunk in job.media_collection:
114
+ for i in chunk:
115
+ self.storage.add_media(tg_file_ids=[i.tg_file_id], media_url=i.effective_url, media_type=i.media_type.value, origin=origin.value)
116
+ else:
117
+ self.storage.add_media(tg_file_ids=[','.join(tg_file_ids)], media_url=job.url, media_type=job.media_type.value, origin=origin.value)
118
+ except Exception as e:
119
+ logging.error("Exception occurred while performing upload callback!")
120
+ logging.exception(e)
121
+
122
+ try:
123
+ # create placeholder message for long download
124
+ placeholder_message_id = await self.bot.placeholder.create(
125
+ chat_id=chat.id,
126
+ reply_id=effective_message_id
127
+ )
128
+
129
+ if not placeholder_message_id:
130
+ await self.bot.send_text(
131
+ chat_id=chat.id,
132
+ reply_id=effective_message_id,
133
+ text="Failed to create message placeholder. Please check your bot Internet connection.")
134
+ return
135
+
136
+ self.bot.uploader.add_callback(
137
+ placeholder_message_id,
138
+ upload_wrapper
139
+ )
140
+
141
+ self.bot.downloader.queue_task(DownloadJob.build(
142
+ url=url,
143
+ placeholder_message_id=placeholder_message_id,
144
+ message_id=effective_message_id,
145
+ chat_id=chat.id,
146
+ in_process=self.bot.uploader.is_inprocess(uniq_id),
147
+ uniq_id=uniq_id,
148
+ job_origin=origin
149
+ ))
150
+ self.bot.uploader.set_inprocess(uniq_id)
151
+ except Exception as e:
152
+ logging.error("Failed to schedule download task!")
153
+ logging.exception(e)
154
+
155
+ if chat.type not in (ChatType.GROUP, ChatType.SUPERGROUP) and not urls:
156
+ await self.bot.send_text(rext=reply_text, reply_id=effective_message_id)
@@ -0,0 +1,191 @@
1
+ import os, io
2
+ import time
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ from pyrogram.types import Message
7
+ from pyrogram.errors import RPCError, FloodWait
8
+ from pyrogram.enums import ParseMode
9
+
10
+ import warp_beacon
11
+ from warp_beacon.telegram.utils import Utils
12
+ from warp_beacon.mediainfo.video import VideoInfo
13
+
14
+ import logging
15
+
16
+ class PlaceholderType(Enum):
17
+ UNKNOWN = 0
18
+ ANIMATION = 1
19
+ DOCUMENT = 2
20
+ PHOTO = 3
21
+
22
+ class PlaceHolder(object):
23
+ pl_type: PlaceholderType = PlaceholderType.ANIMATION
24
+ tg_file_id: str = None
25
+
26
+ def __init__(self, pl_type: PlaceholderType, tg_file_id: str) -> None:
27
+ self.pl_type = pl_type
28
+ self.tg_file_id = tg_file_id
29
+
30
+ class PlaceholderMessage(object):
31
+ bot = None
32
+ placeholder = PlaceHolder(PlaceholderType.ANIMATION, None)
33
+
34
+ def __init__(self, bot: "Bot") -> None:
35
+ self.bot = bot
36
+
37
+ def __del__(self) -> None:
38
+ pass
39
+
40
+ async def reuse_ph_animation(self, chat_id: int, reply_id: int, text: str) -> Optional["types.Message"]:
41
+ reply = await self.bot.client.send_animation(
42
+ chat_id=chat_id,
43
+ animation=self.placeholder.tg_file_id,
44
+ caption=text,
45
+ parse_mode=ParseMode.MARKDOWN,
46
+ reply_to_message_id=reply_id
47
+ )
48
+
49
+ return reply
50
+
51
+ async def reuse_ph_photo(self, chat_id: int, reply_id: int, text: str) -> Optional["types.Message"]:
52
+ reply = await self.bot.client.send_photo(
53
+ chat_id=chat_id,
54
+ photo=self.placeholder.tg_file_id,
55
+ caption=text,
56
+ parse_mode=ParseMode.MARKDOWN,
57
+ reply_to_message_id=reply_id
58
+ )
59
+
60
+ return reply
61
+
62
+ async def reuse_ph_document(self, chat_id: int, reply_id: int, text: str) -> Optional["types.Message"]:
63
+ reply = await self.bot.client.send_document(
64
+ chat_id=chat_id,
65
+ document=self.placeholder.tg_file_id,
66
+ caption=text,
67
+ parse_mode=ParseMode.MARKDOWN,
68
+ reply_to_message_id=reply_id
69
+ )
70
+
71
+ return reply
72
+
73
+ async def create(self, chat_id: int, reply_id: int) -> int:
74
+ retry_amount = 0
75
+ max_retries = int(os.environ.get("TG_MAX_RETRIES", default=5))
76
+ while not retry_amount >= max_retries:
77
+ try:
78
+ text = "**Loading, this may take a moment ...** ⏱️ "
79
+ reply = None
80
+ if self.placeholder.tg_file_id is None:
81
+ ph_found = False
82
+ for ph in ('/var/warp_beacon/placeholder.gif', "%s/../var/warp_beacon/placeholder.gif" % os.path.dirname(os.path.abspath(warp_beacon.__file__))):
83
+ if not os.path.exists(ph):
84
+ continue
85
+ try:
86
+ #pl_info = VideoInfo(ph)
87
+ #pl_resolution = pl_info.get_demensions()
88
+ reply = await self.bot.client.send_document(
89
+ chat_id=chat_id,
90
+ document=ph,
91
+ force_document=False,
92
+ caption=text,
93
+ parse_mode=ParseMode.MARKDOWN,
94
+ reply_to_message_id=reply_id,
95
+ file_name=os.path.basename(ph),
96
+ #width=pl_resolution["width"],
97
+ #height=pl_resolution["height"],
98
+ #duration=round(pl_info.get_duration()),
99
+ #thumb=pl_info.generate_thumbnail()
100
+ )
101
+ self.placeholder = PlaceHolder(PlaceholderType.ANIMATION, Utils.extract_file_id(reply))
102
+ ph_found = True
103
+ break
104
+ except Exception as e:
105
+ logging.warning("Failed to send placeholder message!")
106
+ logging.exception(e)
107
+ if not ph_found:
108
+ try:
109
+ reply = await self.bot.client.send_animation(
110
+ chat_id=chat_id,
111
+ animation="https://bagrintsev.me/warp_beacon/placeholder_that_we_deserve.mp4",
112
+ caption=text,
113
+ parse_mode=ParseMode.MARKDOWN,
114
+ reply_to_message_id=reply_id
115
+ )
116
+ self.placeholder = PlaceHolder(PlaceholderType.ANIMATION, Utils.extract_file_id(reply))
117
+ except Exception as e:
118
+ logging.error("Failed to download secret placeholder!")
119
+ logging.exception(e)
120
+ img = self.create_default_placeholder_img("Loading, this may take a moment ...")
121
+ reply = await self.bot.client.send_photo(
122
+ chat_id=chat.id,
123
+ parse_mode=ParseMode.MARKDOWN,
124
+ reply_to_message_id=reply_id,
125
+ photo=img
126
+ )
127
+ self.placeholder = PlaceHolder(PlaceholderType.PHOTO, Utils.extract_file_id(reply))
128
+ else:
129
+ if self.placeholder.pl_type == PlaceholderType.ANIMATION:
130
+ try:
131
+ reply = await self.reuse_ph_animation(chat_id, reply_id, text)
132
+ except ValueError as e:
133
+ logging.warning("Failed to reuse tg_file_id!")
134
+ logging.exception(e)
135
+ reply = await self.reuse_ph_document(chat_id, reply_id, text)
136
+ self.placeholder.pl_type = PlaceholderType.DOCUMENT
137
+ elif self.placeholder.pl_type == PlaceholderType.DOCUMENT:
138
+ try:
139
+ reply = await self.reuse_ph_document(chat_id, reply_id, text)
140
+ except ValueError as e:
141
+ logging.warning("Failed to reuse tg_file_id!")
142
+ logging.exception(e)
143
+ reply = await self.reuse_ph_animation(chat_id, reply_id, text)
144
+ self.placeholder.pl_type = PlaceholderType.ANIMATION
145
+ else:
146
+ reply = await self.reuse_ph_photo(chat_id, reply_id, text)
147
+ return reply.id
148
+ except FloodWait as e:
149
+ logging.warning("FloodWait exception!")
150
+ logging.exception(e)
151
+ await self.bot.send_text(None, "Telegram error: %s" % e.MESSAGE)
152
+ time.sleep(e.value)
153
+ except Exception as e:
154
+ logging.error("Failed to create placeholder message!")
155
+ logging.exception(e)
156
+ retry_amount += 1
157
+ time.sleep(2)
158
+
159
+ return 0
160
+
161
+ def create_default_placeholder_img(self, text: str, width: int = 800, height: int = 1280) -> io.BytesIO:
162
+ from PIL import Image, ImageDraw, ImageFont
163
+ bio = io.BytesIO()
164
+ bio.name = 'placeholder.png'
165
+ img = Image.new("RGB", (width, height), (255, 255, 255))
166
+ draw = ImageDraw.Draw(img)
167
+ font = ImageFont.load_default(size=48)
168
+ _, _, w, h = draw.textbbox((0, 0), text, font=font)
169
+ draw.text(((width-w)/2, (height-h)/2), text, font=font, fill="#000")
170
+ img.save(bio, 'PNG')
171
+ bio.seek(0)
172
+
173
+ return bio
174
+
175
+ async def update_text(self, chat_id: int, placeholder_message_id: int, placeholder_text: str) -> None:
176
+ try:
177
+ await self.bot.client.edit_message_caption(
178
+ chat_id=chat_id,
179
+ message_id=placeholder_message_id,
180
+ caption=" ⚠️ *%s*" % placeholder_text
181
+ )
182
+ except Exception as e:
183
+ logging.error("Failed to update placeholder message!")
184
+ logging.exception(e)
185
+
186
+ async def remove(self, chat_id: int, placeholder_message_id: int) -> None:
187
+ try:
188
+ await self.bot.client.delete_messages(chat_id, (placeholder_message_id,))
189
+ except Exception as e:
190
+ logging.error("Failed to remove placeholder message!")
191
+ logging.exception(e)
@@ -0,0 +1,73 @@
1
+ import re
2
+
3
+ import requests
4
+
5
+ from enum import Enum
6
+ from typing import Union
7
+
8
+ from warp_beacon.jobs import Origin
9
+ from warp_beacon.jobs.types import JobType
10
+
11
+ import logging
12
+
13
+ class Utils(object):
14
+ expected_patronum_compiled_re = re.compile(r'Expected ([A-Z]+), got ([A-Z]+) file id instead')
15
+
16
+ @staticmethod
17
+ def extract_file_id(message: "Message") -> Union[None, str]:
18
+ possible_attrs = ("video", "photo", "audio", "animation", "document")
19
+ for attr in possible_attrs:
20
+ if hasattr(message, attr):
21
+ _attr = getattr(message, attr, None)
22
+ if _attr:
23
+ tg_id = getattr(_attr, "file_id", None)
24
+ if tg_id:
25
+ return tg_id
26
+ return None
27
+
28
+ @staticmethod
29
+ def extract_origin(url: str) -> Origin:
30
+ if "instagram.com/" in url:
31
+ return Origin.INSTAGRAM
32
+
33
+ if "youtube.com/" in url and "shorts/" in url:
34
+ return Origin.YT_SHORTS
35
+
36
+ if "youtube.com/" in url and "music." in url:
37
+ return Origin.YT_MUSIC
38
+
39
+ if "youtu.be/" in url:
40
+ return Origin.YOUTU_BE
41
+
42
+ if "youtube.com/" in url:
43
+ return Origin.YOUTUBE
44
+
45
+ return Origin.UNKNOWN
46
+
47
+ @staticmethod
48
+ def extract_youtu_be_link(url: str) -> str:
49
+ try:
50
+ response = requests.get(
51
+ url=url,
52
+ allow_redirects=False
53
+ )
54
+ return response.headers["Location"]
55
+ except Exception as e:
56
+ logging.error("Failed to extract YouTube link!")
57
+ logging.exception(e)
58
+
59
+ return ''
60
+
61
+ @staticmethod
62
+ def parse_expected_patronum_error(err_text: str) -> tuple:
63
+ '''
64
+ Input example: 'Expected VIDEO, got ANIMATION file id instead'
65
+ '''
66
+ capture = re.match(Utils.expected_patronum_compiled_re, err_text)
67
+ expected_value, got_value = capture[1], capture[2]
68
+
69
+ return JobType[expected_value], JobType[got_value]
70
+
71
+ @staticmethod
72
+ def chunker(seq: list, size: int) -> list:
73
+ return (seq[pos:pos + size] for pos in range(0, len(seq), size))
@@ -5,12 +5,11 @@ from warp_beacon.jobs.upload_job import UploadJob
5
5
  import logging
6
6
 
7
7
  import asyncio
8
- from telegram import Update
9
- from telegram.ext import ContextTypes
10
8
 
11
9
  from typing import Optional, Callable, Coroutine
12
10
 
13
11
  from warp_beacon.storage import Storage
12
+ from warp_beacon.jobs.types import JobType
14
13
 
15
14
  class AsyncUploader(object):
16
15
  __JOE_BIDEN_WAKEUP = None
@@ -38,12 +37,12 @@ class AsyncUploader(object):
38
37
  thread.start()
39
38
  self.threads.append(thread)
40
39
 
41
- def add_callback(self, message_id: int, callback: Callable, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
40
+ def add_callback(self, message_id: int, callback: Callable) -> None:
42
41
  def callback_wrap(*args, **kwargs) -> None:
43
42
  ret = callback(*args, **kwargs)
44
43
  #self.remove_callback(message_id)
45
44
  return ret
46
- self.callbacks[message_id] = {"callback": callback_wrap, "update": update, "context": context}
45
+ self.callbacks[message_id] = {"callback": callback_wrap}
47
46
 
48
47
  def remove_callback(self, message_id: int) -> None:
49
48
  if message_id in self.callbacks:
@@ -82,15 +81,16 @@ class AsyncUploader(object):
82
81
  if job is self.__JOE_BIDEN_WAKEUP:
83
82
  continue
84
83
  path = ""
85
- if job.media_type == "collection":
84
+ if job.media_type == JobType.COLLECTION:
86
85
  for i in job.media_collection:
87
- path += "%s; " % i.local_media_path
86
+ for j in i:
87
+ path += "%s; " % j.local_media_path
88
88
  else:
89
89
  path = job.local_media_path
90
90
  in_process = job.in_process
91
91
  uniq_id = job.uniq_id
92
92
  message_id = job.placeholder_message_id
93
- if not in_process and not job.job_failed and not job.job_warning:
93
+ if not in_process and not job.job_failed and not job.job_warning:
94
94
  logging.info("Accepted upload job, file(s): '%s'", path)
95
95
  try:
96
96
  if message_id in self.callbacks:
@@ -113,10 +113,10 @@ class AsyncUploader(object):
113
113
  dlds_len = len(db_list_dicts)
114
114
  if dlds_len > 1:
115
115
  job.tg_file_id = ",".join(tg_file_ids)
116
- job.media_type = "collection"
116
+ job.media_type = JobType.COLLECTION
117
117
  elif dlds_len:
118
118
  job.tg_file_id = ",".join(tg_file_ids)
119
- job.media_type = db_list_dicts.pop()["media_type"]
119
+ job.media_type = JobType[db_list_dicts.pop()["media_type"].upper()]
120
120
  asyncio.ensure_future(self.callbacks[message_id]["callback"](job), loop=self.loop)
121
121
  self.process_done(uniq_id)
122
122
  self.remove_callback(message_id)