Compare commits

...
16 Commits
Author SHA1 Message Date
YoursFunny a4e568667c fix type import error 2024-06-20 15:25:17 +08:00
YoursFunny 8231eeb20e refactor tweet, better type hint 2024-06-20 15:13:09 +08:00
YoursFunny 74f42a2df3 fix status code check 2024-06-20 02:51:20 +08:00
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
7 changed files with 177 additions and 114 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ __pycache__/
cert/
data/
docker-compose.yml
x.py
utils/x.py
+12 -2
View File
@@ -2,6 +2,13 @@ import logging
import os
import re
try:
import uvloop, asyncio
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
except ImportError:
uvloop = None
BOT_TOKEN = os.getenv("BOT_TOKEN")
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_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_tco_regex = re.compile(r"(?:https?://)t\.co/.+$", re.M)
message_url_regex = re.compile(r"\[.+]", re.S)
@@ -23,4 +30,7 @@ logging.basicConfig(
level=os.getenv("LOG_LEVEL", "WARNING"),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
+26 -25
View File
@@ -1,23 +1,22 @@
from __future__ import annotations
import html
from functools import wraps
from typing import TYPE_CHECKING
from aiohttp import ClientSession
from telegram import Update, Chat, InlineKeyboardMarkup, InlineKeyboardButton, Message
from telegram.constants import ParseMode, ChatAction, ChatType
from telegram.ext import (
Application,
ApplicationBuilder,
ContextTypes,
Defaults,
filters,
InlineQueryHandler,
PicklePersistence,
MessageHandler,
CommandHandler, CallbackQueryHandler
)
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatAction, ChatType, ParseMode
from telegram.ext import (ApplicationBuilder, CallbackQueryHandler, CommandHandler, Defaults,
InlineQueryHandler, MessageHandler, PicklePersistence, filters)
import common
from tweet import TGTweet
from utils.tweet import Telegram
if TYPE_CHECKING:
from telegram import Chat, Message, Update
from telegram.ext import Application, ContextTypes
logger = common.get_logger(__name__)
def send_action(action):
@@ -36,8 +35,8 @@ async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
query = update.inline_query.query
if query == "":
return
common.logger.info(f"Query: {query}")
async with TGTweet(query) as tweet:
logger.info(f"Query: {query}")
async with Telegram(query) as tweet:
result = list(tweet.inline_query_generator)
await update.inline_query.answer(result)
@@ -45,14 +44,14 @@ async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
@send_action(ChatAction.UPLOAD_PHOTO)
async def url_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
url = update.message.text
common.logger.info(f"Receiving url: {url}")
async with TGTweet(url) as tweet:
media = list(tweet.pm_media_generator)
logger.info(f"Receiving url: {url}")
async with Telegram(url) as tweet:
media = list(tweet.message_media_generator)
message_to_send = await update.effective_message.reply_media_group(
media,
caption=tweet.message_text,
reply_to_message_id=update.message.message_id,
) if not tweet.is_single_gif else await update.effective_message.reply_animation(
) if not isinstance(tuple, media[0]) else await update.effective_message.reply_animation(
media[0][0],
caption=tweet.message_text,
reply_to_message_id=update.message.message_id,
@@ -114,7 +113,7 @@ async def edit_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
update_text[match[0] + 1:match[1] - 1]
) + update_text[match[1]:]
message_to_send = context.user_data['message_to_send']
await message_to_send[0].edit_text(update_text)
await message_to_send[0].edit_caption(update_text)
async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -205,7 +204,7 @@ async def post_init(application: Application) -> None:
DESCRIPTION = "A bot to fetch tweets from Twitter."
await application.bot.set_my_description(DESCRIPTION)
await application.bot.set_my_short_description(DESCRIPTION)
TGTweet.set_session(ClientSession())
Telegram.init_client()
async def post_stop(application: Application) -> None:
@@ -213,7 +212,7 @@ async def post_stop(application: Application) -> None:
async def post_shutdown(application: Application) -> None:
await TGTweet.close_session()
await Telegram.close_client()
def main():
@@ -226,6 +225,8 @@ def main():
.post_init(post_init)
.post_stop(post_stop)
.post_shutdown(post_shutdown)
.concurrent_updates(True)
.http_version('2')
.build()
)
@@ -233,8 +234,8 @@ def main():
user_filter.add_user_ids(common.ADMIN)
handlers = [
MessageHandler(filters.Regex(common.x_url_regex) & filters.ChatType.PRIVATE, url_media),
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("remove_forward_channel", cmd_remove_forward_channel),
CommandHandler("edit_before_forward", cmd_edit_before_forward),
+3 -2
View File
@@ -1,2 +1,3 @@
python-telegram-bot[webhooks]~=21.1.1
aiohttp[speedups]~=3.9.3
python-telegram-bot[webhooks]~=21.3
httpx[http2]~=0.27.0
uvloop~=0.19.0; sys_platform != 'win32'
View File
+118 -84
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
import html
from typing import Generator
from functools import cached_property
from typing import Generator, TYPE_CHECKING
from uuid import uuid4
from aiohttp import ClientSession
from telegram import (
InlineQueryResultPhoto,
InlineQueryResultVideo,
InlineQueryResultMpeg4Gif,
InputMediaPhoto,
InputMediaVideo
)
from httpx import AsyncClient
from telegram import (InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto,
InputMediaVideo)
from common import get_logger, x_media_regex, x_tco_regex, x_url_regex
if TYPE_CHECKING:
from .types import TweetInfo, TypeInlineQueryResult, TypeMessageMediaResult
logger = get_logger(__name__)
from common import x_url_regex, x_media_regex, x_tco_regex, logger
twimg_url = 'https://pbs.twimg.com/'
vx_api_url = 'https://api.vxtwitter.com/{0}/status/{1}'
@@ -20,30 +25,39 @@ 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}")
async with session.get(url) as response:
assert response.status == 200, f"Failed to fetch {url}, status code {response.status}"
return await response.json()
response = await _client.get(url)
assert response.is_success, f"Failed to fetch {url}, status code {response.status_code}"
return response.json()
class TweetMedia:
__slots__ = ('_url', '_thumb', '_type', '__dict__')
def __init__(self, url: str, thumb: str, media_type: str):
self._url: str = url
self._thumb: str = thumb
self._type: str = media_type
def __str__(self):
return f"Media[url: {self.url} thumb: {self.thumb} type: {self.type}]"
return f"TweetMedia(url={self.url}, thumb={self.thumb}, type={self.type})"
@property
@cached_property
def _uri(self) -> str | None:
match = x_media_regex.match(self._url)
if match:
if match := x_media_regex.match(self._url):
return match.group(2).removesuffix('.jpg').removesuffix('.png')
return None
@property
@cached_property
def url(self) -> str:
match self._type:
case "image":
@@ -55,7 +69,7 @@ class TweetMedia:
case _:
return self._url
@property
@cached_property
def thumb(self) -> str:
match self._type:
case "image":
@@ -73,6 +87,8 @@ class TweetMedia:
class Tweet:
__slots__ = ('_id', '_author', '_author_id', '_text', '_media', '_sensitive', '__dict__')
def __init__(
self,
tweet_id: str,
@@ -93,7 +109,7 @@ class Tweet:
def id(self) -> str:
return self._id
@property
@cached_property
def url(self) -> str:
return f"https://twitter.com/{self._author_id}/status/{self._id}"
@@ -101,7 +117,7 @@ class Tweet:
def author(self) -> str:
return self._author
@property
@cached_property
def author_url(self) -> str:
return f"https://twitter.com/{self._author_id}"
@@ -118,40 +134,32 @@ class Tweet:
return self._sensitive
class TGTweet(Tweet):
_session: ClientSession
class ProcessTweet:
__slots__ = ('_httpx_client', '_url', '_tweet')
def __init__(self, url: str):
def __init__(self, httpx_client: AsyncClient, url: str):
self._httpx_client: AsyncClient = httpx_client
self._url: str = url
self._api_param: tuple[str] = self._tweet_id
self._is_single_gif: bool = False
assert self._api_param
async def __aenter__(self):
self._tweet: dict = await self._fetch_tweet(self._api_param)
super().__init__(*self._init_properties)
return 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
@classmethod
def set_session(cls, session: ClientSession) -> None:
cls._session = session
@classmethod
async def close_session(cls) -> None:
await cls._session.close()
async def _fetch_tweet(self, api_param: tuple[str]) -> dict:
return await fetch_json(self._session, vx_api_url.format(*api_param))
@property
def _tweet_id(self) -> tuple[str] | None:
async def _fetch_tweet(self) -> TweetInfo:
match = x_url_regex.match(self._url)
if match:
return match.groups()
return None
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:
@@ -162,87 +170,113 @@ class TGTweet(Tweet):
def _tweet_media(self) -> list[TweetMedia]:
return [
TweetMedia(
url=x['url'],
thumb=x['thumbnail_url'],
media_type=x['type']
url=tweet_media['url'],
thumb=tweet_media['thumbnail_url'],
media_type=tweet_media['type']
)
for x in self._tweet['media_extended']
for tweet_media in self._tweet['media_extended']
]
@property
def _init_properties(self) -> tuple:
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']
return id, author, author_id, text, media, sensitive
@property
def is_single_gif(self) -> bool:
return self._is_single_gif
class TelegramTweet:
__slots__ = ('_httpx_client', '_url', '_tweet', '__dict__')
@property
def __init__(self, httpx_client: AsyncClient, url: str):
self._httpx_client: AsyncClient = httpx_client
self._url: str = url
async def __aenter__(self):
async with ProcessTweet(self._httpx_client, self._url) as tweet:
self._tweet = tweet
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@cached_property
def url(self) -> str:
return self._tweet.url
@cached_property
def message_text(self) -> str:
tweet = self._tweet
return message_raw_text.format(
url=self.url,
author_url=self.author_url,
author=html.escape(self.author),
text=html.escape(self.text)
url=tweet.url,
author_url=tweet.author_url,
author=html.escape(tweet.author),
text=html.escape(tweet.text)
)
@property
def inline_query_generator(self) -> Generator[
InlineQueryResultPhoto | InlineQueryResultVideo | InlineQueryResultMpeg4Gif, None, None
]:
for i, tweet_media in enumerate(self.media):
def inline_query_generator(self) -> Generator[TypeInlineQueryResult, None, None]:
tweet = self._tweet
for tweet_media in tweet.media:
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InlineQueryResultPhoto(
id=str(i),
id=str(uuid4()),
photo_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text
)
elif tweet_media.type == "video":
yield InlineQueryResultVideo(
id=str(i),
id=str(uuid4()),
video_url=tweet_media.url,
mime_type="video/mp4",
thumbnail_url=tweet_media.thumb,
title=self.text,
title=tweet.text,
caption=self.message_text
)
elif tweet_media.type == "gif":
yield InlineQueryResultMpeg4Gif(
id=str(i),
id=str(uuid4()),
mpeg4_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text
)
@property
def pm_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo | tuple[str, bool], None, None]:
for tweet_media in self.media:
def message_media_generator(self) -> Generator[TypeMessageMediaResult, None, None]:
tweet = self._tweet
for tweet_media in tweet.media:
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InputMediaPhoto(
media=tweet_media.url,
has_spoiler=self.sensitive
has_spoiler=tweet.sensitive
)
elif tweet_media.type == "video":
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=self.sensitive,
has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb
)
elif tweet_media.type == "gif":
if len(self.media) == 1:
self._is_single_gif = True
yield tweet_media.url, self.sensitive
if len(tweet.media) == 1:
yield tweet_media.url, tweet.sensitive
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=self.sensitive,
has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb
)
class Telegram:
_httpx_client: AsyncClient
def __init__(self, url: str):
self._url = url
async def __aenter__(self):
if x_url_regex.match(self._url):
async with TelegramTweet(self._httpx_client, self._url) as tweet:
return tweet
@classmethod
def init_client(cls) -> None:
cls._httpx_client = create_client()
@classmethod
async def close_client(cls) -> None:
await close_client(cls._httpx_client)
+17
View File
@@ -0,0 +1,17 @@
from typing import TypedDict
from telegram import InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto, \
InputMediaVideo
TypeInlineQueryResult = InlineQueryResultMpeg4Gif | InlineQueryResultPhoto | InlineQueryResultVideo
InputMediaAnimation = tuple[str, bool]
TypeMessageMediaResult = InputMediaPhoto | InputMediaVideo | InputMediaAnimation
class TweetInfo(TypedDict):
tweetID: str
user_name: str
user_screen_name: str
text: str
media_extended: list[dict]
possibly_sensitive: bool