Compare commits

..
58 Commits
Author SHA1 Message Date
YoursFunny 87f0d16028 fix regex https pattern 2024-07-16 14:28:49 +08:00
YoursFunny 197993e522 bump version 2024-07-16 14:27:15 +08:00
YoursFunny 02abcd899e update pixiv regex 2024-07-16 14:21:43 +08:00
YoursFunny ebb48d2bb5 fix forward channel check admin user 2024-07-09 16:27:13 +08:00
YoursFunny be77a33e9d use large instead of origin for pixiv 2024-06-24 20:02:35 +08:00
YoursFunny 0439adefa0 add refresh token if expire 2024-06-24 15:57:30 +08:00
YoursFunny 81e5d2dbd1 add log 2024-06-24 15:47:12 +08:00
YoursFunny e7ccd70e56 fix deal with invalid url 2024-06-24 02:17:26 +08:00
YoursFunny 23128af96a fix regex 2024-06-24 02:14:50 +08:00
YoursFunny fe1724aca4 fix annotations 2024-06-24 02:06:25 +08:00
YoursFunny 0433650563 fix init pixiv 2024-06-24 02:03:28 +08:00
YoursFunny a617a7e1a0 fix requirement conflict 2024-06-24 01:48:45 +08:00
YoursFunny e35e3895ba fix requirement conflict 2024-06-24 01:45:23 +08:00
YoursFunny a4a9f2eaa0 add pixiv package requirement 2024-06-24 01:29:29 +08:00
YoursFunny 1c779e98a8 add pixiv support 2024-06-24 01:25:03 +08:00
YoursFunny 4a5aa296ce add pixiv refresh token setting 2024-06-23 22:14:58 +08:00
YoursFunny c1c1d42892 refactor, clear structure 2024-06-23 22:06:27 +08:00
YoursFunny cade370538 refactor 2024-06-23 21:52:12 +08:00
YoursFunny a6325d5bc8 fix check instance 2024-06-20 15:46:47 +08:00
YoursFunny 6c0e581135 fix regex match group 2024-06-20 15:40:18 +08:00
YoursFunny 494fc50446 fix missing aexit 2024-06-20 15:29:30 +08:00
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
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
YoursFunny f2fac6a9e0 fix edit message 2024-05-13 00:24:03 +08:00
YoursFunny e3dcb6e59b fix send gif 2024-05-13 00:19:24 +08:00
YoursFunny a1d1dc5ea3 fix template replace 2024-05-13 00:00:08 +08:00
YoursFunny e629899a72 fix missing return 2024-05-12 23:51:19 +08:00
YoursFunny d079271ef9 fix action 2024-05-12 23:42:18 +08:00
YoursFunny 1526c7900c add set template 2024-05-12 23:34:11 +08:00
YoursFunny 79d68b2e82 fix int admin id 2024-04-28 01:57:10 +08:00
YoursFunny e700ae65ae add message user dict debug 2024-04-28 01:36:36 +08:00
YoursFunny 9ac6065366 remove dict key if disable edit forward 2024-04-28 01:33:50 +08:00
YoursFunny 40877b52eb fix confirm 2024-04-28 01:26:43 +08:00
YoursFunny 0ca9b55ea8 try edit reply markup 2024-04-28 01:18:35 +08:00
YoursFunny ef17b05c14 fix html escape only message 2024-04-28 01:12:02 +08:00
YoursFunny 85d0257c51 feat: change forward confirm reply markup 2024-04-28 01:08:22 +08:00
YoursFunny 7f90670f1c fix edit message html escape 2024-04-28 01:01:59 +08:00
YoursFunny 755c405196 update ptb version 2024-04-26 15:49:52 +08:00
YoursFunny 0ffa9903c4 add hint 2024-04-26 15:19:04 +08:00
YoursFunny 248e7d48e0 fix caption of multiple inline query results 2024-04-26 15:15:13 +08:00
YoursFunny e915cd13de fix html escape 2024-04-26 15:13:38 +08:00
14 changed files with 528 additions and 152 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ __pycache__/
cert/ cert/
data/ data/
docker-compose.yml docker-compose.yml
x.py utils/x.py
+1 -1
View File
@@ -7,7 +7,7 @@ ENV PYTHONUNBUFFERED=1
RUN set -eux; \ RUN set -eux; \
apt-get update; \ apt-get update; \
apt-get install -y gosu; \ apt-get install -y git gosu; \
rm -rf /var/lib/apt/lists/*; \ rm -rf /var/lib/apt/lists/*; \
# verify that the binary works # verify that the binary works
gosu nobody true gosu nobody true
+11 -14
View File
@@ -1,9 +1,17 @@
import logging
import os import os
import re
try:
import uvloop
import 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 = os.getenv("BOT_ADMIN").split(",") ADMIN = [int(i) for i in os.getenv("BOT_ADMIN").split(",")]
PIXIV_REFRESH_TOKEN = os.getenv("PIXIV_REFRESH_TOKEN")
WEBHOOK = os.getenv("WEBHOOK", False) WEBHOOK = os.getenv("WEBHOOK", False)
if WEBHOOK: if WEBHOOK:
@@ -13,14 +21,3 @@ if WEBHOOK:
WEBHOOK_KEY = os.getenv("WEBHOOK_KEY", "cert/private.key") WEBHOOK_KEY = os.getenv("WEBHOOK_KEY", "cert/private.key")
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_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)
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "WARNING"),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
+3 -2
View File
@@ -1,12 +1,13 @@
services: services:
tgxmb: image: yoursfunny/telegram-twitter-media-bot: latest tgxmb: image: yoursfunny/telegram-twitter-media-bot: latest
restart: always restart: always
ports: # ports:
- "8443:8443" # - "8443:8443"
environment: environment:
LOCAL_USER_ID: '1000' LOCAL_USER_ID: '1000'
BOT_TOKEN: '' BOT_TOKEN: ''
BOT_ADMIN: '' BOT_ADMIN: ''
PIXIV_REFRESH_TOKEN: ''
WEBHOOK: false WEBHOOK: false
WEBHOOK_LISTEN: '127.0.0.1' WEBHOOK_LISTEN: '127.0.0.1'
WEBHOOK_PORT: 8443 WEBHOOK_PORT: 8443
+101 -46
View File
@@ -1,22 +1,26 @@
from functools import wraps from __future__ import annotations
from aiohttp import ClientSession import html
from telegram import Update, Chat, InlineKeyboardMarkup, InlineKeyboardButton, ForceReply, Message from functools import wraps
from telegram.constants import ParseMode, ChatAction, ChatType from typing import TYPE_CHECKING
from telegram.ext import (
Application, from telegram import InlineKeyboardButton, InlineKeyboardMarkup
ApplicationBuilder, from telegram.constants import ChatAction, ChatType, ParseMode
ContextTypes, from telegram.ext import (ApplicationBuilder, CallbackQueryHandler, CommandHandler, Defaults,
Defaults, InlineQueryHandler, MessageHandler, PicklePersistence, filters)
filters,
InlineQueryHandler,
PicklePersistence,
MessageHandler,
CommandHandler, CallbackQueryHandler
)
import common import common
from tweet import TGTweet import utils.regex as regex
from utils.logger import get_logger
from utils.net import NetClient
from utils.pixiv import ProcessPixiv
from utils.telegram import Telegram
if TYPE_CHECKING:
from telegram import Message, Update
from telegram.ext import Application, ContextTypes
logger = get_logger(__name__)
def send_action(action): def send_action(action):
@@ -35,28 +39,41 @@ 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 Telegram(query) as tweet:
result = list(tweet.inline_query_generator) if not tweet:
await update.inline_query.answer(result) return
await update.inline_query.answer(tweet.inline_query_result())
@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 Telegram(url) as tweet:
media = list(tweet.pm_media_generator) media = tweet.message_media_result()
if not media:
await update.effective_message.reply_text("No media found or media type is not supported.")
return
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,
reply_to_message_id=update.message.message_id, reply_to_message_id=update.message.message_id,
) if not isinstance(media[0], tuple) else await update.effective_message.reply_animation(
media[0][0],
caption=tweet.message_text,
reply_to_message_id=update.message.message_id,
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(
"Reply to edit message.", "Reply to edit message. [URL]",
reply_markup=ForceReply(selective=True, input_field_placeholder="{URL}"), reply_markup=InlineKeyboardMarkup.from_button(
InlineKeyboardButton("↩️ Confirm", callback_data="forward")
),
reply_to_message_id=update.message.message_id, reply_to_message_id=update.message.message_id,
) )
context.user_data['message_reply'] = message_reply context.user_data['message_reply'] = message_reply
@@ -86,27 +103,32 @@ async def edit_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
return return
if update.message.reply_to_message != context.user_data['message_reply']: if update.message.reply_to_message != context.user_data['message_reply']:
return return
update_text = update.message.text template = context.user_data.get('template', None)
match = common.message_url_regex.search(update_text) message_url = '<a href="{0}">{1}</a>'
if match: url = context.user_data['message_url']
match = match.span() if template:
update_text = update_text[:match[0]] + '<a href="{0}">{1}</a>'.format( update_text = template.replace("[]", message_url.format(
context.user_data['message_url'], update_text[match[0] + 1:match[1] - 1] url,
) + update_text[match[1]:] html.escape(update.message.text)
))
else:
update_text = html.escape(update.message.text)
match = regex.message_url.search(update_text)
if match:
match = match.span()
update_text = update_text[:match[0]] + message_url.format(
url,
update_text[match[0] + 1:match[1] - 1]
) + update_text[match[1]:]
message_to_send = context.user_data['message_to_send'] message_to_send = context.user_data['message_to_send']
await message_to_send[0].edit_caption( await message_to_send[0].edit_caption(update_text)
update_text,
reply_markup=InlineKeyboardMarkup.from_button(
InlineKeyboardButton("↩️ Confirm", callback_data="forward")
)
)
async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
message_to_send = context.user_data['message_to_send'] message_to_send = context.user_data['message_to_send']
await forward_message(update, context, message_to_send) await forward_message(update, context, message_to_send)
await update.callback_query.answer('✅ Forwarded') await update.callback_query.answer('✅ Forwarded')
await update.callback_query.edit_message_reply_markup() await update.callback_query.delete_message()
del context.user_data['message_reply'] del context.user_data['message_reply']
del context.user_data['message_to_send'] del context.user_data['message_to_send']
del context.user_data['message_url'] del context.user_data['message_url']
@@ -119,7 +141,7 @@ async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_
return return
channel = context.args[0] channel = context.args[0]
try: try:
channel: Chat = await context.bot.get_chat(channel) channel = await context.bot.get_chat(channel)
except Exception as e: except Exception as e:
await update.effective_message.reply_text(str(e)) await update.effective_message.reply_text(str(e))
return return
@@ -131,6 +153,11 @@ async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_
except Exception as e: except Exception as e:
await update.effective_message.reply_text(str(e) + "\nPlease add the bot to the channel and set as admin") await update.effective_message.reply_text(str(e) + "\nPlease add the bot to the channel and set as admin")
return return
user = filter(lambda x: x.user.id == update.effective_user.id, channel_admin)
user = next(user, None)
if not user:
await update.effective_message.reply_text("You are not an admin of the channel.")
return
user_bot = filter(lambda x: x.user.id == context.bot.id, channel_admin) user_bot = filter(lambda x: x.user.id == context.bot.id, channel_admin)
user_bot = next(user_bot, None) user_bot = next(user_bot, None)
if user_bot.can_post_messages: if user_bot.can_post_messages:
@@ -155,12 +182,33 @@ async def cmd_edit_before_forward(update: Update, context: ContextTypes.DEFAULT_
ebf_status = context.user_data.get('edit_before_forward', False) ebf_status = context.user_data.get('edit_before_forward', False)
if ebf_status: if ebf_status:
context.user_data['edit_before_forward'] = False context.user_data['edit_before_forward'] = False
context.user_data.pop('message_reply', None)
context.user_data.pop('message_to_send', None)
context.user_data.pop('message_url', None)
await update.effective_message.reply_text("Disable edit before forward.") await update.effective_message.reply_text("Disable edit before forward.")
return return
context.user_data['edit_before_forward'] = True context.user_data['edit_before_forward'] = True
await update.effective_message.reply_text("Enable edit before forward.") await update.effective_message.reply_text("Enable edit before forward.")
@send_action(ChatAction.TYPING)
async def cmd_set_template(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
reply = update.effective_message.reply_to_message
if not reply:
await update.effective_message.reply_text("Please reply to a message to set as template.")
return
if '[]' not in reply.text_html:
await update.effective_message.reply_text("Please reply to a message with [] to set as template.")
return
context.user_data['template'] = reply.text_html
await update.effective_message.reply_text("Template set.")
@send_action(ChatAction.TYPING)
async def cmd_user_dict(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text(str(context.user_data))
async def post_init(application: Application) -> None: async def post_init(application: Application) -> None:
# commands = [ # commands = [
# BotCommand('start', CMD_START), # BotCommand('start', CMD_START),
@@ -169,7 +217,9 @@ 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()) NetClient.init_client()
if common.PIXIV_REFRESH_TOKEN:
await ProcessPixiv.init_client(common.PIXIV_REFRESH_TOKEN)
async def post_stop(application: Application) -> None: async def post_stop(application: Application) -> None:
@@ -177,7 +227,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 NetClient.close_client()
def main(): def main():
@@ -190,20 +240,25 @@ 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)
.http_version('2')
.build() .build()
) )
# user_filter = filters.User() user_filter = filters.User()
# 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),
InlineQueryHandler(inline_query, common.x_url_regex), MessageHandler((filters.Regex(regex.x_url) | filters.Regex(regex.pixiv_url)) & 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),
CommandHandler("set_template", cmd_set_template),
MessageHandler(~filters.COMMAND & filters.ChatType.PRIVATE, edit_message), MessageHandler(~filters.COMMAND & filters.ChatType.PRIVATE, edit_message),
CallbackQueryHandler(query_forward_message, pattern="forward"), CallbackQueryHandler(query_forward_message, pattern="forward"),
CommandHandler("bot_dict", cmd_user_dict, filters=user_filter),
] ]
application.add_handlers(handlers) application.add_handlers(handlers)
+4 -2
View File
@@ -1,2 +1,4 @@
python-telegram-bot[webhooks]~=21.0.1 python-telegram-bot[webhooks]~=21.4
aiohttp[speedups]~=3.9.3 httpx[http2]~=0.27
uvloop~=0.19.0; sys_platform != 'win32'
async-pixiv @ git+https://github.com/TheFunny/async-pixiv@main
View File
+11
View File
@@ -0,0 +1,11 @@
import logging
import os
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "WARNING"),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from httpx import AsyncClient
def create_client() -> AsyncClient:
return AsyncClient(http2=True)
async def close_client(_client: AsyncClient) -> None:
return await _client.aclose()
async def fetch_json(_client: AsyncClient, url: str) -> dict:
response = await _client.get(url)
assert response.is_success, f"Failed to fetch {url}, status code {response.status_code}"
return response.json()
class NetClient:
_httpx_client: AsyncClient
@classmethod
def init_client(cls) -> None:
cls._httpx_client = create_client()
@classmethod
async def close_client(cls) -> None:
await close_client(cls._httpx_client)
@classmethod
def get_client(cls) -> AsyncClient:
return cls._httpx_client
@classmethod
async def fetch_json(cls, url: str) -> dict:
return await fetch_json(cls._httpx_client, url)
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import html
from typing import Generator, Literal, TYPE_CHECKING
from uuid import uuid4
from async_pixiv import PixivClient
from async_pixiv.error import ApiError
from telegram import InlineQueryResultPhoto, InputMediaPhoto
from utils.logger import get_logger
if TYPE_CHECKING:
from async_pixiv.model.illust import Illust
from utils.types import TypeInlineQueryResult, TypeMessageMediaResult
logger = get_logger(__name__)
message_raw_text = """<a href="{url}">{text}</a> / <a href="{author_url}">{author}</a>
{tags}
"""
class PixivMedia:
__slots__ = ('_url', '_thumb')
def __init__(self, url: str, thumb: str):
self._url: str = url
self._thumb: str = thumb
def __str__(self):
return f"PixivMedia(url={self.url}, thumb={self.thumb}, large={self.large})"
@property
def url(self) -> str:
return self._url
@property
def thumb(self) -> str:
return self._thumb
@property
def large(self) -> str:
url = self._url.replace("img-original", "img-master").removesuffix(".jpg").removesuffix(".png")
return url + "_master1200.jpg"
class Pixiv:
__slots__ = ('_illust',)
def __init__(self, illust: Illust):
self._illust: Illust = illust
@property
def url(self) -> str:
return str(self._illust.link).rstrip('/')
@property
def type(self) -> Literal["illust", "manga", "ugoira"]:
return self._illust.type.value
@property
def title(self) -> str:
return self._illust.title
@property
def author(self) -> str:
return self._illust.user.name
@property
def author_url(self) -> str:
return str(self._illust.user.link).rstrip('/')
@property
def description(self) -> str:
return self._illust.caption
@property
def tags(self) -> list[str]:
return [tag.name for tag in self._illust.tags]
@property
def is_multiple_pages(self) -> bool:
return self._illust.page_count > 1
@property
def is_nsfw(self) -> bool:
return self._illust.is_nsfw
@property
def is_ai(self) -> bool:
return self._illust.ai_type.value == 2
@property
def images(self) -> list[PixivMedia]:
if self.is_multiple_pages:
return [
PixivMedia(
url=page.image_urls.original,
thumb=page.image_urls.medium
)
for page in self._illust.meta_pages
]
else:
return [
PixivMedia( # should observe if image_url.original is always None for single page
url=self._illust.image_urls.original or self._illust.meta_single_page.original,
thumb=self._illust.image_urls.medium
)
]
class ProcessPixiv:
_client: PixivClient
__slots__ = ('_url', '_illust')
@classmethod
async def init_client(cls, token: str) -> None:
cls._client = PixivClient()
await cls._client.login_with_token(token)
cls._token = token
@classmethod
async def close_client(cls) -> None:
await cls._client.close()
@classmethod
async def refresh_token(cls):
await cls._client.login_with_token(cls._token)
def __init__(self, url: str):
self._url: str = url
async def __aenter__(self):
self._illust = await self._fetch_illust()
return Pixiv(self._illust)
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
async def _fetch_illust(self) -> Illust:
illust_id = self._parse_illust_id()
try:
return (await self._client.ILLUST.detail(illust_id)).illust
except ApiError:
await self.refresh_token()
return (await self._client.ILLUST.detail(illust_id)).illust # TODO use retry here
def _parse_illust_id(self) -> int:
return int(self._url.split("/")[-1])
class TelegramPixiv:
__slots__ = ('_url', '_pixiv')
def __init__(self, url: str):
self._url = url
async def __aenter__(self):
async with ProcessPixiv(self._url) as pixiv:
self._pixiv = pixiv
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
def url(self) -> str:
return self._url
@property
def message_text(self) -> str:
pixiv = self._pixiv
return message_raw_text.format(
url=pixiv.url,
author_url=pixiv.author_url,
author=html.escape(pixiv.author),
text=html.escape(pixiv.title),
tags=html.escape(" ".join(f"#{name}" for name in pixiv.tags))
)
def inline_query_result(self) -> tuple[TypeInlineQueryResult, ...]:
return tuple(self.inline_query_generator())
def message_media_result(self) -> tuple[TypeMessageMediaResult, ...]:
return tuple(self.message_media_generator())
def inline_query_generator(self) -> Generator[TypeInlineQueryResult, None, None]:
pixiv = self._pixiv
for media in pixiv.images:
logger.info(str(media))
if pixiv.type in ("illust", "manga"):
yield InlineQueryResultPhoto(
id=str(uuid4()),
photo_url=media.large,
thumbnail_url=media.thumb,
caption=self.message_text
)
else:
yield
def message_media_generator(self) -> Generator[TypeMessageMediaResult, None, None]:
pixiv = self._pixiv
for media in pixiv.images:
logger.info(str(media))
if pixiv.type in ("illust", "manga"):
yield InputMediaPhoto(
media=media.large,
has_spoiler=pixiv.is_nsfw
)
else:
yield
+8
View File
@@ -0,0 +1,8 @@
import re
x_url = re.compile(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter)\.com/(.+)/status/(\d+)")
x_media_url = re.compile(r"^(?:https?://)?(pbs|video)\.twimg\.com/(.*)")
x_tco_url = re.compile(r"(?:https?://)?t\.co/.+$", re.M)
message_url = re.compile(r"\[.+]", re.S)
pixiv_url = re.compile(r"^(?:https?://)?(?:www\.)?pixiv\.net/(?:en/)?(?:artworks/|i/)(\d+)")
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from common import PIXIV_REFRESH_TOKEN
from .pixiv import TelegramPixiv
from .regex import pixiv_url, x_url
from .tweet import TelegramTweet
class Telegram:
def __init__(self, url: str):
self._url = url
async def __aenter__(self):
if x_url.match(self._url):
async with TelegramTweet(self._url) as tweet:
return tweet
elif PIXIV_REFRESH_TOKEN and pixiv_url.match(self._url):
async with TelegramPixiv(self._url) as pixiv:
return pixiv
else:
return None # TODO add raise and catch
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
+98 -86
View File
@@ -1,16 +1,22 @@
from typing import Generator from __future__ import annotations
from aiohttp import ClientSession import html
from telegram import ( from functools import cached_property
InlineQueryResultPhoto, from typing import Generator, TYPE_CHECKING
InlineQueryResultVideo, from uuid import uuid4
InlineQueryResultMpeg4Gif,
InputMediaPhoto, from telegram import (InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto,
InputMediaVideo, InputMediaVideo)
InputMediaAnimation
) from .logger import get_logger
from .net import NetClient
from .regex import x_media_url, x_tco_url, x_url
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/' 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,30 +26,24 @@ message_raw_text = """{url}
""" """
async def fetch_json(session: ClientSession, 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()
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
self._type: str = media_type self._type: str = media_type
def __str__(self): 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: def _uri(self) -> str | None:
match = x_media_regex.match(self._url) if match := x_media_url.match(self._url):
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 +55,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 +73,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 +95,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 +103,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,121 +120,131 @@ class Tweet:
return self._sensitive return self._sensitive
class TGTweet(Tweet): class ProcessTweet:
_session: ClientSession __slots__ = ('_url', '_tweet')
def __init__(self, url: str): def __init__(self, url: str):
self._url: str = url self._url: str = url
self._api_param: tuple[str] = self._tweet_id
assert self._api_param
async def __aenter__(self): async def __aenter__(self):
self._tweet: dict = await self._fetch_tweet(self._api_param) self._tweet = await self._fetch_tweet()
super().__init__(*self._init_properties) return Tweet(
return self 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): async def __aexit__(self, exc_type, exc_val, exc_tb):
pass pass
@classmethod async def _fetch_tweet(self) -> TweetInfo:
def set_session(cls, session: ClientSession) -> None: match = x_url.match(self._url)
cls._session = session assert match, f"Invalid URL: {self._url}"
auther_id, tweet_id = match.groups()
@classmethod return await NetClient.fetch_json(vx_api_url.format(auther_id, tweet_id))
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:
match = x_url_regex.match(self._url)
if match:
return match.groups()
return None
@property @property
def _tweet_text(self) -> str: def _tweet_text(self) -> str:
match = x_tco_regex.search(self._tweet['text']) match = x_tco_url.search(self._tweet['text'])
return self._tweet['text'][:match.start()].strip(" ") if match else self._tweet['text'] return self._tweet['text'][:match.start()].strip(" ") if match else self._tweet['text']
@property @property
def _tweet_media(self) -> list[TweetMedia]: def _tweet_media(self) -> list[TweetMedia]:
return [ return [
TweetMedia( TweetMedia(
url=x['url'], url=tweet_media['url'],
thumb=x['thumbnail_url'], thumb=tweet_media['thumbnail_url'],
media_type=x['type'] 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 class TelegramTweet:
__slots__ = ('_url', '_tweet', '__dict__')
def __init__(self, url: str):
self._url: str = url
async def __aenter__(self):
async with ProcessTweet(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: def message_text(self) -> str:
tweet = self._tweet
return message_raw_text.format( return message_raw_text.format(
url=self.url, url=tweet.url,
author_url=self.author_url, author_url=tweet.author_url,
author=self.author, author=html.escape(tweet.author),
text=self.text text=html.escape(tweet.text)
) )
@property def inline_query_result(self) -> tuple[TypeInlineQueryResult, ...]:
def inline_query_generator(self) -> Generator[InlineQueryResultPhoto | InlineQueryResultVideo, None, None]: return tuple(self.inline_query_generator())
for i, tweet_media in enumerate(self.media):
def message_media_result(self) -> tuple[TypeMessageMediaResult, ...]:
return tuple(self.message_media_generator())
def inline_query_generator(self) -> Generator[TypeInlineQueryResult, None, None]:
tweet = self._tweet
for tweet_media in tweet.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 if not i else None 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,
title=self.text, title=tweet.text,
caption=self.message_text if not i else None caption=self.message_text
) )
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 if not i else None caption=self.message_text
) )
@property def message_media_generator(self) -> Generator[TypeMessageMediaResult, None, None]:
def pm_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo, None, None]: tweet = self._tweet
for tweet_media in self.media: for tweet_media in tweet.media:
logger.info(str(tweet_media)) logger.info(str(tweet_media))
if tweet_media.type == "image": if tweet_media.type == "image":
yield InputMediaPhoto( yield InputMediaPhoto(
media=tweet_media.url, media=tweet_media.url,
has_spoiler=self.sensitive has_spoiler=tweet.sensitive
) )
elif tweet_media.type == "video": elif tweet_media.type == "video":
yield InputMediaVideo( yield InputMediaVideo(
media=tweet_media.url, media=tweet_media.url,
has_spoiler=self.sensitive, has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb thumbnail=tweet_media.thumb
) )
elif tweet_media.type == "gif": elif tweet_media.type == "gif":
yield InputMediaAnimation( if len(tweet.media) == 1:
yield tweet_media.url, tweet.sensitive
yield InputMediaVideo(
media=tweet_media.url, media=tweet_media.url,
has_spoiler=self.sensitive, has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb thumbnail=tweet_media.thumb
) )
+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