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