Compare commits

...
16 Commits
Author SHA1 Message Date
YoursFunny ae1f5a8c8c rename 2024-06-20 02:48:06 +08:00
YoursFunny 38adbf0cb8 use cached property 2024-06-20 02:40:35 +08:00
YoursFunny 55ae73ee6b use uuid for query 2024-06-20 02:21:14 +08:00
YoursFunny d6589fec5b fix annotations 2024-06-20 02:20:10 +08:00
YoursFunny 91cad86325 use http2 in httpx 2024-06-20 02:11:39 +08:00
YoursFunny cfdb05e476 add use uvloop 2024-06-20 01:41:40 +08:00
YoursFunny 6df6a950c8 update to httpx 2024-06-19 23:19:42 +08:00
YoursFunny 78d185723b add type checking import 2024-06-19 22:47:10 +08:00
YoursFunny 1252ec8804 fix import logger 2024-06-19 01:42:50 +08:00
YoursFunny cde1091c85 update call concurrently 2024-06-19 01:39:59 +08:00
YoursFunny 8b2b6b49ab update regex pattern 2024-06-19 01:03:16 +08:00
YoursFunny 86b4a972c9 bump version 2024-06-19 00:58:23 +08:00
YoursFunny 483a3de649 fix edit message caption 2024-05-13 00:55:49 +08:00
YoursFunny 48bc81898d fix edit message tuple 2024-05-13 00:47:37 +08:00
YoursFunny f660bc2c5e fix edit message url 2024-05-13 00:38:36 +08:00
YoursFunny 7747dab4a3 fix edit message caption 2024-05-13 00:32:17 +08:00
4 changed files with 150 additions and 65 deletions
+12 -2
View File
@@ -2,6 +2,13 @@ import logging
import os import os
import re import re
try:
import uvloop, asyncio
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
except ImportError:
uvloop = None
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
ADMIN = [int(i) for i in os.getenv("BOT_ADMIN").split(",")] ADMIN = [int(i) for i in os.getenv("BOT_ADMIN").split(",")]
@@ -14,7 +21,7 @@ if WEBHOOK:
WEBHOOK_CERT = os.getenv("WEBHOOK_CERT", "cert/cert.pem") WEBHOOK_CERT = os.getenv("WEBHOOK_CERT", "cert/cert.pem")
WEBHOOK_SECRET_TOKEN = os.getenv("WEBHOOK_SECRET_TOKEN") WEBHOOK_SECRET_TOKEN = os.getenv("WEBHOOK_SECRET_TOKEN")
x_url_regex = re.compile(r"^(?:https?://)(?:www\.|mobile\.|)(?:x|twitter)\.com/(.+)/status/(\d+)") x_url_regex = re.compile(r"^(?:https?://)(?:www\.|mobile\.|)(?:x|twitter|fixvx|vxtwitter)\.com/(.+)/status/(\d+)")
x_media_regex = re.compile(r"^(?:https?://)(pbs|video)\.twimg\.com/(.*)") x_media_regex = re.compile(r"^(?:https?://)(pbs|video)\.twimg\.com/(.*)")
x_tco_regex = re.compile(r"(?:https?://)t\.co/.+$", re.M) x_tco_regex = re.compile(r"(?:https?://)t\.co/.+$", re.M)
message_url_regex = re.compile(r"\[.+]", re.S) message_url_regex = re.compile(r"\[.+]", re.S)
@@ -23,4 +30,7 @@ logging.basicConfig(
level=os.getenv("LOG_LEVEL", "WARNING"), level=os.getenv("LOG_LEVEL", "WARNING"),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) )
logger = logging.getLogger(__name__)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
+34 -30
View File
@@ -1,23 +1,22 @@
from __future__ import annotations
import html import html
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING
from aiohttp import ClientSession from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram import Update, Chat, InlineKeyboardMarkup, InlineKeyboardButton, Message from telegram.constants import ChatAction, ChatType, ParseMode
from telegram.constants import ParseMode, ChatAction, ChatType from telegram.ext import (ApplicationBuilder, CallbackQueryHandler, CommandHandler, Defaults,
from telegram.ext import ( InlineQueryHandler, MessageHandler, PicklePersistence, filters)
Application,
ApplicationBuilder,
ContextTypes,
Defaults,
filters,
InlineQueryHandler,
PicklePersistence,
MessageHandler,
CommandHandler, CallbackQueryHandler
)
import common import common
from tweet import TGTweet from tweet import TelegramTweet
if TYPE_CHECKING:
from telegram import Chat, Message, Update
from telegram.ext import Application, ContextTypes
logger = common.get_logger(__name__)
def send_action(action): def send_action(action):
@@ -36,8 +35,8 @@ async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
query = update.inline_query.query query = update.inline_query.query
if query == "": if query == "":
return return
common.logger.info(f"Query: {query}") logger.info(f"Query: {query}")
async with TGTweet(query) as tweet: async with TelegramTweet(query) as tweet:
result = list(tweet.inline_query_generator) result = list(tweet.inline_query_generator)
await update.inline_query.answer(result) await update.inline_query.answer(result)
@@ -45,9 +44,9 @@ async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
@send_action(ChatAction.UPLOAD_PHOTO) @send_action(ChatAction.UPLOAD_PHOTO)
async def url_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def url_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
url = update.message.text url = update.message.text
common.logger.info(f"Receiving url: {url}") logger.info(f"Receiving url: {url}")
async with TGTweet(url) as tweet: async with TelegramTweet(url) as tweet:
media = list(tweet.pm_media_generator) media = list(tweet.message_media_generator)
message_to_send = await update.effective_message.reply_media_group( message_to_send = await update.effective_message.reply_media_group(
media, media,
caption=tweet.message_text, caption=tweet.message_text,
@@ -58,6 +57,8 @@ async def url_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
reply_to_message_id=update.message.message_id, reply_to_message_id=update.message.message_id,
has_spoiler=media[0][1] has_spoiler=media[0][1]
) )
if not isinstance(message_to_send, tuple):
message_to_send = (message_to_send,)
url = tweet.url url = tweet.url
if context.user_data.get('edit_before_forward', False): if context.user_data.get('edit_before_forward', False):
message_reply = await update.effective_message.reply_text( message_reply = await update.effective_message.reply_text(
@@ -95,22 +96,24 @@ async def edit_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
if update.message.reply_to_message != context.user_data['message_reply']: if update.message.reply_to_message != context.user_data['message_reply']:
return return
template = context.user_data.get('template', None) template = context.user_data.get('template', None)
message_url = '<a href="{0}">{1}</a>'
url = context.user_data['message_url']
if template: if template:
update_text = template.replace("[]", html.escape(update.message.text)) update_text = template.replace("[]", message_url.format(
url,
html.escape(update.message.text)
))
else: else:
update_text = html.escape(update.message.text) update_text = html.escape(update.message.text)
match = common.message_url_regex.search(update_text) match = common.message_url_regex.search(update_text)
if match: if match:
match = match.span() match = match.span()
update_text = update_text[:match[0]] + '<a href="{0}">{1}</a>'.format( update_text = update_text[:match[0]] + message_url.format(
context.user_data['message_url'], url,
update_text[match[0] + 1:match[1] - 1] update_text[match[0] + 1:match[1] - 1]
) + update_text[match[1]:] ) + update_text[match[1]:]
message_to_send = context.user_data['message_to_send'] message_to_send = context.user_data['message_to_send']
if isinstance(message_to_send, tuple): await message_to_send[0].edit_caption(update_text)
await message_to_send[0].edit_text(update_text)
else:
await message_to_send.edit_text(update_text)
async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -201,7 +204,7 @@ async def post_init(application: Application) -> None:
DESCRIPTION = "A bot to fetch tweets from Twitter." DESCRIPTION = "A bot to fetch tweets from Twitter."
await application.bot.set_my_description(DESCRIPTION) await application.bot.set_my_description(DESCRIPTION)
await application.bot.set_my_short_description(DESCRIPTION) await application.bot.set_my_short_description(DESCRIPTION)
TGTweet.set_session(ClientSession()) TelegramTweet.init_client()
async def post_stop(application: Application) -> None: async def post_stop(application: Application) -> None:
@@ -209,7 +212,7 @@ async def post_stop(application: Application) -> None:
async def post_shutdown(application: Application) -> None: async def post_shutdown(application: Application) -> None:
await TGTweet.close_session() await TelegramTweet.close_client()
def main(): def main():
@@ -222,6 +225,7 @@ def main():
.post_init(post_init) .post_init(post_init)
.post_stop(post_stop) .post_stop(post_stop)
.post_shutdown(post_shutdown) .post_shutdown(post_shutdown)
.concurrent_updates(True)
.build() .build()
) )
@@ -229,8 +233,8 @@ def main():
user_filter.add_user_ids(common.ADMIN) user_filter.add_user_ids(common.ADMIN)
handlers = [ handlers = [
MessageHandler(filters.Regex(common.x_url_regex) & filters.ChatType.PRIVATE, url_media),
InlineQueryHandler(inline_query, common.x_url_regex), InlineQueryHandler(inline_query, common.x_url_regex),
MessageHandler(filters.Regex(common.x_url_regex) & filters.ChatType.PRIVATE, url_media),
CommandHandler("set_forward_channel", cmd_set_forward_channel), CommandHandler("set_forward_channel", cmd_set_forward_channel),
CommandHandler("remove_forward_channel", cmd_remove_forward_channel), CommandHandler("remove_forward_channel", cmd_remove_forward_channel),
CommandHandler("edit_before_forward", cmd_edit_before_forward), CommandHandler("edit_before_forward", cmd_edit_before_forward),
+3 -2
View File
@@ -1,2 +1,3 @@
python-telegram-bot[webhooks]~=21.1.1 python-telegram-bot[webhooks]~=21.3
aiohttp[speedups]~=3.9.3 httpx[http2]~=0.27.0
uvloop~=0.19.0; sys_platform != 'win32'
+101 -31
View File
@@ -1,16 +1,30 @@
from __future__ import annotations
import html import html
from typing import Generator from functools import cached_property
from typing import TYPE_CHECKING
from uuid import uuid4
from aiohttp import ClientSession from httpx import AsyncClient
from telegram import ( from telegram import (InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto,
InlineQueryResultPhoto, InputMediaVideo)
InlineQueryResultVideo,
InlineQueryResultMpeg4Gif, from common import get_logger, x_media_regex, x_tco_regex, x_url_regex
InputMediaPhoto,
InputMediaVideo if TYPE_CHECKING:
) from typing import Generator, TypedDict
class TweetInfo(TypedDict):
tweetID: str
user_name: str
user_screen_name: str
text: str
media_extended: list[dict]
possibly_sensitive: bool
logger = get_logger(__name__)
from common import x_url_regex, x_media_regex, x_tco_regex, logger
twimg_url = 'https://pbs.twimg.com/' twimg_url = 'https://pbs.twimg.com/'
vx_api_url = 'https://api.vxtwitter.com/{0}/status/{1}' vx_api_url = 'https://api.vxtwitter.com/{0}/status/{1}'
@@ -20,14 +34,24 @@ message_raw_text = """{url}
""" """
async def fetch_json(session: ClientSession, url: str) -> dict: def create_client() -> AsyncClient:
return AsyncClient(http2=True)
async def close_client(_client: AsyncClient) -> None:
await _client.aclose()
async def fetch_json(_client: AsyncClient, url: str) -> dict:
logger.info(f"Fetching {url}") logger.info(f"Fetching {url}")
async with session.get(url) as response: response = await _client.get(url)
assert response.status == 200, f"Failed to fetch {url}, status code {response.status}" assert response.status_code == response.is_success, f"Failed to fetch {url}, status code {response.status_code}"
return await response.json() return response.json()
class TweetMedia: class TweetMedia:
__slots__ = ('_url', '_thumb', '_type', '__dict__')
def __init__(self, url: str, thumb: str, media_type: str): def __init__(self, url: str, thumb: str, media_type: str):
self._url: str = url self._url: str = url
self._thumb: str = thumb self._thumb: str = thumb
@@ -36,14 +60,14 @@ class TweetMedia:
def __str__(self): def __str__(self):
return f"Media[url: {self.url} thumb: {self.thumb} type: {self.type}]" return f"Media[url: {self.url} thumb: {self.thumb} type: {self.type}]"
@property @cached_property
def _uri(self) -> str | None: def _uri(self) -> str | None:
match = x_media_regex.match(self._url) match = x_media_regex.match(self._url)
if match: if match:
return match.group(2).removesuffix('.jpg').removesuffix('.png') return match.group(2).removesuffix('.jpg').removesuffix('.png')
return None return None
@property @cached_property
def url(self) -> str: def url(self) -> str:
match self._type: match self._type:
case "image": case "image":
@@ -55,7 +79,7 @@ class TweetMedia:
case _: case _:
return self._url return self._url
@property @cached_property
def thumb(self) -> str: def thumb(self) -> str:
match self._type: match self._type:
case "image": case "image":
@@ -73,6 +97,8 @@ class TweetMedia:
class Tweet: class Tweet:
__slots__ = ('_id', '_author', '_author_id', '_text', '_media', '_sensitive', '__dict__')
def __init__( def __init__(
self, self,
tweet_id: str, tweet_id: str,
@@ -93,7 +119,7 @@ class Tweet:
def id(self) -> str: def id(self) -> str:
return self._id return self._id
@property @cached_property
def url(self) -> str: def url(self) -> str:
return f"https://twitter.com/{self._author_id}/status/{self._id}" return f"https://twitter.com/{self._author_id}/status/{self._id}"
@@ -101,7 +127,7 @@ class Tweet:
def author(self) -> str: def author(self) -> str:
return self._author return self._author
@property @cached_property
def author_url(self) -> str: def author_url(self) -> str:
return f"https://twitter.com/{self._author_id}" return f"https://twitter.com/{self._author_id}"
@@ -118,8 +144,52 @@ class Tweet:
return self._sensitive return self._sensitive
class TGTweet(Tweet): class ProcessTweet:
_session: ClientSession __slots__ = ('_httpx_client', '_url', '_tweet')
def __init__(self, httpx_client: AsyncClient, url: str):
self._httpx_client: AsyncClient = httpx_client
self._url: str = url
async def __aenter__(self):
self._tweet = await self._fetch_tweet()
return Tweet(
tweet_id=self._tweet["tweetID"],
author=self._tweet["user_name"],
author_id=self._tweet["user_screen_name"],
text=self._tweet_text,
media=self._tweet_media,
sensitive=self._tweet["possibly_sensitive"]
)
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
async def _fetch_tweet(self) -> TweetInfo:
match = x_url_regex.match(self._url)
assert match, f"Invalid URL: {self._url}"
auther_id, tweet_id = match.group()
return await fetch_json(self._httpx_client, vx_api_url.format(auther_id, tweet_id))
@property
def _tweet_text(self) -> str:
match = x_tco_regex.search(self._tweet['text'])
return self._tweet['text'][:match.start()].strip(" ") if match else self._tweet['text']
@property
def _tweet_media(self) -> list[TweetMedia]:
return [
TweetMedia(
url=tweet_media['url'],
thumb=tweet_media['thumbnail_url'],
media_type=tweet_media['type']
)
for tweet_media in self._tweet['media_extended']
]
class TelegramTweet(Tweet):
_httpx_client: AsyncClient
def __init__(self, url: str): def __init__(self, url: str):
self._url: str = url self._url: str = url
@@ -136,15 +206,15 @@ class TGTweet(Tweet):
pass pass
@classmethod @classmethod
def set_session(cls, session: ClientSession) -> None: def init_client(cls) -> None:
cls._session = session cls._httpx_client = create_client()
@classmethod @classmethod
async def close_session(cls) -> None: async def close_client(cls) -> None:
await cls._session.close() await close_client(cls._httpx_client)
async def _fetch_tweet(self, api_param: tuple[str]) -> dict: async def _fetch_tweet(self, api_param: tuple[str]) -> dict:
return await fetch_json(self._session, vx_api_url.format(*api_param)) return await fetch_json(self._httpx_client, vx_api_url.format(*api_param))
@property @property
def _tweet_id(self) -> tuple[str] | None: def _tweet_id(self) -> tuple[str] | None:
@@ -196,18 +266,18 @@ class TGTweet(Tweet):
def inline_query_generator(self) -> Generator[ def inline_query_generator(self) -> Generator[
InlineQueryResultPhoto | InlineQueryResultVideo | InlineQueryResultMpeg4Gif, None, None InlineQueryResultPhoto | InlineQueryResultVideo | InlineQueryResultMpeg4Gif, None, None
]: ]:
for i, tweet_media in enumerate(self.media): for tweet_media in self.media:
logger.info(str(tweet_media)) logger.info(str(tweet_media))
if tweet_media.type == "image": if tweet_media.type == "image":
yield InlineQueryResultPhoto( yield InlineQueryResultPhoto(
id=str(i), id=str(uuid4()),
photo_url=tweet_media.url, photo_url=tweet_media.url,
thumbnail_url=tweet_media.thumb, thumbnail_url=tweet_media.thumb,
caption=self.message_text caption=self.message_text
) )
elif tweet_media.type == "video": elif tweet_media.type == "video":
yield InlineQueryResultVideo( yield InlineQueryResultVideo(
id=str(i), id=str(uuid4()),
video_url=tweet_media.url, video_url=tweet_media.url,
mime_type="video/mp4", mime_type="video/mp4",
thumbnail_url=tweet_media.thumb, thumbnail_url=tweet_media.thumb,
@@ -216,14 +286,14 @@ class TGTweet(Tweet):
) )
elif tweet_media.type == "gif": elif tweet_media.type == "gif":
yield InlineQueryResultMpeg4Gif( yield InlineQueryResultMpeg4Gif(
id=str(i), id=str(uuid4()),
mpeg4_url=tweet_media.url, mpeg4_url=tweet_media.url,
thumbnail_url=tweet_media.thumb, thumbnail_url=tweet_media.thumb,
caption=self.message_text caption=self.message_text
) )
@property @property
def pm_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo | tuple[str, bool], None, None]: def message_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo | tuple[str, bool], None, None]:
for tweet_media in self.media: for tweet_media in self.media:
logger.info(str(tweet_media)) logger.info(str(tweet_media))
if tweet_media.type == "image": if tweet_media.type == "image":