main
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3# ty:ignore[invalid-argument-type]
4import asyncio
5from pathlib import Path
6
7from loguru import logger
8from pyrogram.client import Client
9from pyrogram.errors import FloodWait
10from pyrogram.parser.markdown import BLOCKQUOTE_DELIM, BLOCKQUOTE_EXPANDABLE_DELIM
11from pyrogram.types import Message, ReplyParameters
12
13from config import CAPTION_LENGTH, TID
14from messages.parser import get_thread_id
15from messages.preprocess import preprocess_media, warp_media_group
16from messages.progress import modify_progress, telegram_uploading
17from messages.utils import better_blockquote, blockquote, delete_message, get_reply_to, remove_img_tag, smart_split, summay_media
18from utils import to_int
19
20
21async def send2tg(
22 client: Client,
23 message: Message,
24 target_chat: int | str = "",
25 reply_msg_id: int = 0,
26 thread_id: int = 0,
27 *,
28 texts: str = "",
29 media: list[dict] | None = None,
30 comments: list[str] | None = None, # append after texts
31 send_from_user: str | None = None,
32 cooldown: float = 0,
33 caption_above: bool = False,
34 keep_file: bool = False,
35 **kwargs,
36) -> list[Message]:
37 """Send unlimited number of texts and media to Telegram.
38
39 Telegram Message Limitation:
40 - 4096 characters for pure texts
41 - 10 media in a single message
42 - 1024 characters for caption (4096 for premium user)
43
44 Args:
45 client (Client): The Pyrogram client.
46 message (Message): The trigger message object.
47 target_chat (int | str, optional): The chat ID to send the message.
48 reply_msg_id (int, optional): If set to integer > 0, the result is sent as a reply message to this message_id.
49 If set to 0, reply to the trigger message itself.
50 If set to -1, do not send as a reply message.
51 thread_id (int | None, optional): The thread ID to send the message to. Defaults to None.
52 texts (str, optional): The texts to send.
53 media (list[dict], optional): The media files to send.
54 comments (list[str], optional): The comments to append after texts.
55 send_from_user (str, optional): The user name to prefix the texts.
56 cooldown (float, optional): The interval between each media message. Defaults to 0.
57 caption_above (bool, optional): Show caption above the message media.
58 keep_file (bool, optional): Keep the media files after sending. Defaults to False.
59 kwargs: Other keyword arguments. In this function, we use:
60 show_progress (bool, optional): Show a progress message on Telegram. Defaults to True.
61 detail_progress (bool, optional): Show detailed progress (Only if show_proress is set to True). Defaults to False.
62
63 media item format:
64 [
65 {
66 "photo": "path/to/photo.jpg",
67 },
68 {
69 "video": "path/to/video.mp4",
70 }
71 ]
72 """
73 if not target_chat:
74 target_chat = kwargs["target_chat"] if kwargs.get("target_chat") else message.chat.id
75 target_chat = to_int(target_chat)
76 reply_parameters = get_reply_to(message.id, reply_msg_id)
77 tid = thread_id or get_thread_id(message)
78 media = media or []
79 media = await preprocess_media(media)
80 texts = texts or ""
81 if comments: # append comments to texts
82 texts = texts + "".join(comments)
83
84 if send_from_user: # prefix send_from_user
85 texts = f"{send_from_user}{texts.strip()}"
86 texts, _ = remove_img_tag(texts)
87 if kwargs.get("progress") and len(media) > 0:
88 await modify_progress(text=f"⏫正在上传:\n{summay_media(media)}", force_update=True, **kwargs)
89 sent_messages: list[Message] = [] # return sent messages
90 logger.trace(f"Sending {len(media)} media with {len(texts)} texts")
91 if len(media) == 0:
92 return await send_texts(client, target_chat, reply_parameters, tid, texts=texts, cooldown=cooldown)
93 if len(media) == 1:
94 return await send_single_media(
95 client,
96 target_chat,
97 reply_parameters,
98 tid,
99 media=media[0],
100 texts=texts,
101 cooldown=cooldown,
102 caption_above=caption_above,
103 keep_file=keep_file,
104 **kwargs,
105 )
106
107 caption = (await smart_split(texts, CAPTION_LENGTH))[0]
108 remaining_texts = texts.removeprefix(caption)
109 caption = better_blockquote(caption)
110 if 1 < len(media) <= 10:
111 group = await warp_media_group(media, caption=caption, caption_above=caption_above)
112 sent_messages.extend(await send_media_group(client, target_chat, group, reply_parameters, tid))
113 else: # media > 10
114 media_chunks = [media[i : i + 10] for i in range(0, len(media), 10)]
115 num_chunk = len(media_chunks)
116 # send pure media first, and append captions at the last chunk
117 for idx, batch in enumerate(media_chunks):
118 if idx == 0: # first chunk
119 group = await warp_media_group(batch)
120 sent_messages.extend(await send_media_group(client, target_chat, group, reply_parameters, tid))
121 elif idx != num_chunk - 1: # disbale reply if not the last chunk
122 group = await warp_media_group(batch)
123 sent_messages.extend(await send_media_group(client, target_chat, group, None, tid))
124 else: # last chunk: media <= 10, add caption here
125 sent_messages.extend(
126 await send2tg(
127 client, message, target_chat, reply_msg_id=-1, thread_id=tid, texts=caption, media=batch, caption_above=caption_above, cooldown=cooldown, keep_file=keep_file, **kwargs
128 )
129 )
130 await asyncio.sleep(cooldown)
131 if remaining_texts:
132 sent_messages.extend(await send_texts(client, target_chat, None, tid, texts=remaining_texts, cooldown=cooldown))
133 # clean up
134 for x in media:
135 for key in ["path", "media", "thumb", "audio", "photo", "video"]:
136 if x.get(key) and Path(x[key]).is_file() and not keep_file:
137 logger.trace(f"Deleting: {x[key]}")
138 Path(x[key]).unlink(missing_ok=True)
139 return sent_messages
140
141
142async def send_texts(
143 client: Client,
144 target_chat: int | str,
145 reply_parameters: ReplyParameters | None,
146 thread_id: int = 0,
147 *,
148 texts: str = "",
149 cooldown: float = 0,
150) -> list[Message]:
151 sent_messages: list[Message] = []
152 logger.trace(f"Sending {len(texts)} texts only")
153 for idx, msg in enumerate(await smart_split(texts.strip())):
154 if not msg:
155 continue
156 # we do not send comments-only texts
157 if (f"{BLOCKQUOTE_EXPANDABLE_DELIM}💬" in msg or f"{BLOCKQUOTE_DELIM}💬" in msg or "<b>💬" in msg) and "点击展开评论" not in msg:
158 continue
159 if idx != 0:
160 reply_parameters = None
161 try:
162 message = await client.send_message(target_chat, better_blockquote(msg), message_thread_id=thread_id, reply_parameters=reply_parameters)
163 except FloodWait as e:
164 logger.warning(e)
165 await asyncio.sleep(e.value)
166 message = await client.send_message(target_chat, better_blockquote(msg), message_thread_id=thread_id, reply_parameters=reply_parameters)
167 except Exception as e:
168 logger.warning(f"send_texts: {e}")
169 await asyncio.sleep(cooldown)
170 if isinstance(message, Message):
171 sent_messages.append(message)
172 return sent_messages
173
174
175async def send_single_media(
176 client: Client,
177 target_chat: int | str,
178 reply_parameters: ReplyParameters | None,
179 thread_id: int = 0,
180 *,
181 media: dict,
182 texts: str = "",
183 cooldown: float = 0,
184 caption_above: bool = False,
185 keep_file: bool = False,
186 **kwargs,
187) -> list[Message]:
188 sent_messages: list[Message] = []
189 logger.trace(f"Sending single media with {len(texts)} texts")
190 caption = (await smart_split(texts, CAPTION_LENGTH))[0]
191 remaining_texts = texts.removeprefix(caption)
192 caption = better_blockquote(caption)
193 message = None
194 try:
195 if media.get("photo"):
196 message = await client.send_photo(chat_id=target_chat, **media, caption=caption, show_caption_above_media=caption_above, reply_parameters=reply_parameters, message_thread_id=thread_id)
197 elif video := media.get("video"):
198 message = await client.send_video(
199 chat_id=target_chat,
200 reply_parameters=reply_parameters,
201 message_thread_id=thread_id,
202 caption=caption,
203 show_caption_above_media=caption_above,
204 progress=telegram_uploading,
205 progress_args=(kwargs.get("progress", False), video, kwargs.get("detail_progress", True)),
206 **media,
207 )
208
209 elif audio := media.get("audio"):
210 message = await client.send_audio(
211 chat_id=target_chat,
212 reply_parameters=reply_parameters,
213 message_thread_id=thread_id,
214 caption=caption,
215 progress=telegram_uploading,
216 progress_args=(kwargs.get("progress", False), audio, kwargs.get("detail_progress", True)),
217 **media,
218 )
219
220 elif document := media.get("document"):
221 message = await client.send_document(
222 chat_id=target_chat,
223 reply_parameters=reply_parameters,
224 message_thread_id=thread_id,
225 caption=caption,
226 progress=telegram_uploading,
227 progress_args=(kwargs.get("progress", False), document, kwargs.get("detail_progress", True)),
228 **media,
229 )
230 except FloodWait as e:
231 logger.warning(e)
232 await asyncio.sleep(e.value)
233 return await send_single_media(
234 client,
235 target_chat,
236 reply_parameters,
237 thread_id,
238 media=media,
239 texts=texts,
240 cooldown=cooldown,
241 keep_file=keep_file,
242 **kwargs,
243 )
244 except Exception as e:
245 logger.warning(f"send_single_media: {e}")
246 if isinstance(message, Message):
247 sent_messages.append(message)
248 if remaining_texts:
249 sent_messages.extend(await send_texts(client, target_chat, None, thread_id, texts=remaining_texts, cooldown=cooldown))
250
251 for key in ["path", "thumb", "audio", "photo", "video"]:
252 if media.get(key) and Path(media[key]).is_file() and not keep_file:
253 logger.trace(f"Deleting: {media[key]}")
254 Path(media[key]).unlink(missing_ok=True)
255 return sent_messages
256
257
258async def send_media_group(
259 client: Client,
260 target_chat: int | str,
261 media_group: list,
262 reply_parameters: ReplyParameters,
263 thread_id: int = 0,
264) -> list[Message]:
265 """Send a media group to Telegram.
266
267 If we get FloodWait error, the function will retry after the specified time.
268 But unfortunately, we can not know how many media files have been sent.
269 So the retry mechanism will send the same media group again, casuing the repeated media files.
270 To avoid this, we first send the media group to a temperary chat, and then copy the messages to the target chat.
271 """
272
273 async def send(chat_id: int | str, media_group: list, reply_parameters: ReplyParameters | None = None, retry: int = 0) -> list[Message]:
274 if retry > 2:
275 return []
276 try:
277 messages = await client.send_media_group(chat_id, media=media_group, reply_parameters=reply_parameters, message_thread_id=thread_id)
278 return [m for m in messages if isinstance(m, Message)]
279 except FloodWait as e:
280 logger.warning(e)
281 await asyncio.sleep(e.value)
282 except Exception as e:
283 logger.error(f"Failed to send_media_group: {e}")
284 return await send(chat_id, media_group, reply_parameters, retry=retry + 1)
285
286 async def copy_media_group(message_id: int, retry: int = 0) -> list[Message]:
287 if retry > 2:
288 return []
289 try:
290 messages = await client.copy_media_group(to_int(target_chat), from_chat_id=to_int(TID.TEMP), message_id=message_id, reply_parameters=reply_parameters, message_thread_id=thread_id)
291 return [m for m in messages if isinstance(m, Message)]
292 except FloodWait as e:
293 logger.warning(e)
294 await asyncio.sleep(e.value)
295 except Exception as e:
296 logger.error(f"Failed to copy_media_group: {e}")
297 return await copy_media_group(message_id, retry=retry + 1)
298
299 # According to our observation, FloodWait usually occurs when len(media_group) > 6
300 # if len(media_group) > 6: # This is no longer working
301 if False:
302 sent = []
303 temp_msgs = await send(to_int(TID.TEMP), media_group)
304 if len(temp_msgs) > 0 and isinstance(temp_msgs[0], Message) and temp_msgs[0].media_group_id:
305 sent = await copy_media_group(message_id=temp_msgs[0].id)
306 [await delete_message(m) for m in temp_msgs]
307 return [m for m in sent if isinstance(m, Message)]
308
309 # send directly
310 return await send(to_int(target_chat), media_group, reply_parameters)
311
312
313async def send_blockquote_texts(
314 client: Client,
315 message: Message,
316 target_chat: int | str = "",
317 reply_msg_id: int = 0,
318 thread_id: int = 0,
319 *,
320 texts: str = "",
321 cooldown: float = 1,
322 **kwargs,
323) -> list[Message]:
324 if not target_chat:
325 target_chat = kwargs["target_chat"] if kwargs.get("target_chat") else message.chat.id
326 target_chat = to_int(target_chat)
327 reply_parameters = get_reply_to(message.id, reply_msg_id)
328 tid = thread_id or get_thread_id(message)
329 if not texts.strip():
330 return []
331 texts, _ = remove_img_tag(texts)
332 sent_messages: list[Message] = []
333 cur_msg = None
334 logger.trace(f"Sending {len(texts)} blockquote texts:")
335 for msg in await smart_split(texts.strip()):
336 if not msg:
337 continue
338 try:
339 text = blockquote(msg)
340 if not isinstance(cur_msg, Message):
341 cur_msg = await client.send_message(target_chat, better_blockquote(text), message_thread_id=tid, reply_parameters=reply_parameters)
342 else:
343 cur_msg = await cur_msg.reply_text(better_blockquote(text), quote=True)
344 reply_parameters = get_reply_to(cur_msg.id, reply_msg_id)
345 except FloodWait as e:
346 logger.warning(e)
347 await asyncio.sleep(e.value)
348 cur_msg = await client.send_message(target_chat, better_blockquote(msg), message_thread_id=thread_id, reply_parameters=reply_parameters)
349 except Exception as e:
350 logger.warning(f"send_texts: {e}")
351 await asyncio.sleep(cooldown)
352 if isinstance(cur_msg, Message):
353 sent_messages.append(cur_msg)
354 return sent_messages