Compare commits

..
34 Commits
Author SHA1 Message Date
YoursFunny 052163084b change to x.com in url template 2025-12-17 17:24:22 +08:00
YoursFunny cdee105c60 bump deps version 2025-12-17 17:23:20 +08:00
YoursFunny fae3dad43a fix start command handler 2025-08-21 10:30:10 +08:00
YoursFunny f4c53719d8 bump dep version and fix start command handler 2025-08-21 10:23:25 +08:00
YoursFunny cf44c3ddd7 minor changes and adding /start command 2025-08-21 09:59:37 +08:00
YoursFunny cbb15e6a33 fix tag check 2024-11-12 17:35:11 +08:00
YoursFunny afc31430e7 new bsky sensitive contents 2024-11-12 17:23:46 +08:00
YoursFunny 282cf46d58 minor fix 2024-11-12 17:12:47 +08:00
YoursFunny 6c53a1429d update to python 3.13 2024-11-10 19:02:18 +08:00
YoursFunny ca9064a1b7 bump version 2024-11-10 18:19:29 +08:00
YoursFunny d58fd7cd46 add clear edit message command 2024-11-10 18:17:51 +08:00
YoursFunny 9aa7aa1c36 fix external bsky media (disable) 2024-11-10 18:17:15 +08:00
YoursFunny 9c2606160a format 2024-10-19 22:41:41 +08:00
YoursFunny 3fe8a1cf3b new bsky support 2024-10-19 22:25:07 +08:00
YoursFunny 62a2820af3 add params support in fetch_json 2024-10-19 22:23:51 +08:00
YoursFunny 80b630d28d minor fixes 2024-10-19 22:22:27 +08:00
YoursFunny 4173972407 fix empty env bot_admin 2024-10-19 22:21:56 +08:00
YoursFunny 0d2648162c bump version 2024-10-19 22:19:44 +08:00
YoursFunny 0847ada3b7 update gitignore 2024-10-18 00:21:19 +08:00
YoursFunny f0c287a148 fix await 2024-08-15 17:45:14 +08:00
YoursFunny 3658bfd2aa fix handler 2024-08-15 17:42:19 +08:00
YoursFunny ae1b04dee8 add extract url from message 2024-08-15 17:34:40 +08:00
YoursFunny 641f7218a4 minor fixes 2024-08-15 16:45:36 +08:00
YoursFunny 61dfa16011 fix edit message str format 2024-08-15 16:45:06 +08:00
YoursFunny 2986e80076 fix edit message 2024-08-14 01:52:01 +08:00
YoursFunny f112ecdf63 fix text 2024-08-14 01:30:08 +08:00
YoursFunny 79d50ae9a3 fix 2024-08-14 01:19:50 +08:00
YoursFunny 416331303d fix 2024-08-14 01:14:01 +08:00
YoursFunny 55771e01fe fix 2024-08-14 01:13:47 +08:00
YoursFunny afca276f49 minor fixes 2024-08-14 00:57:20 +08:00
YoursFunny 06d25854d2 fix error import 2024-08-14 00:37:01 +08:00
YoursFunny 515aa9711d new set template 2024-08-14 00:29:02 +08:00
YoursFunny 733adfc4a6 refactor using custom callback context 2024-08-13 23:20:24 +08:00
YoursFunny 03066853de refactor 2024-08-13 19:17:58 +08:00
13 changed files with 692 additions and 269 deletions
+7
View File
@@ -4,3 +4,10 @@ cert/
data/ data/
docker-compose.yml docker-compose.yml
utils/x.py utils/x.py
.env
# Added by cargo
/target
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim-bullseye FROM python:3.13-slim-bullseye
LABEL maintainer="admin@yoursfunny.top" LABEL maintainer="admin@yoursfunny.top"
+1 -1
View File
@@ -9,7 +9,7 @@ except ImportError:
uvloop = None 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(",") if i]
PIXIV_REFRESH_TOKEN = os.getenv("PIXIV_REFRESH_TOKEN") PIXIV_REFRESH_TOKEN = os.getenv("PIXIV_REFRESH_TOKEN")
+131 -75
View File
@@ -4,13 +4,14 @@ import html
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from telegram import InlineKeyboardButton, InlineKeyboardMarkup from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity
from telegram.constants import ChatAction, ChatType, ParseMode from telegram.constants import ChatAction, ChatType, ParseMode
from telegram.ext import (ApplicationBuilder, CallbackQueryHandler, CommandHandler, Defaults, from telegram.ext import (ApplicationBuilder, CallbackQueryHandler, CommandHandler, ContextTypes, Defaults,
InlineQueryHandler, MessageHandler, PicklePersistence, filters) InlineQueryHandler, MessageHandler, PicklePersistence, filters)
import common import common
import utils.regex as regex import utils.regex as regex
from utils.context import ChatData, CustomContext, EditMessage
from utils.logger import get_logger from utils.logger import get_logger
from utils.net import NetClient from utils.net import NetClient
from utils.pixiv import ProcessPixiv from utils.pixiv import ProcessPixiv
@@ -18,7 +19,7 @@ from utils.telegram import Telegram
if TYPE_CHECKING: if TYPE_CHECKING:
from telegram import Message, Update from telegram import Message, Update
from telegram.ext import Application, ContextTypes from telegram.ext import Application
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -26,16 +27,26 @@ logger = get_logger(__name__)
def send_action(action): def send_action(action):
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
async def command_func(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): async def command_func(update: Update, context: CustomContext, *args, **kwargs):
await update.effective_chat.send_action(action) try:
return await func(update, context, *args, **kwargs) await update.effective_chat.send_action(action)
finally:
return await func(update, context, *args, **kwargs)
return command_func return command_func
return decorator return decorator
async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: def extract_urls(message: Message) -> set[str]:
types = [MessageEntity.URL, MessageEntity.TEXT_LINK]
res = message.parse_entities(types)
res.update(message.parse_caption_entities(types))
res.update({key: key.url for key in res if key.type == MessageEntity.TEXT_LINK})
return set(res.values())
async def inline_query(update: Update, context: CustomContext) -> None:
query = update.inline_query.query query = update.inline_query.query
if query == "": if query == "":
return return
@@ -47,13 +58,16 @@ 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: CustomContext, url: str) -> None:
url = update.message.text
logger.info(f"Receiving url: {url}")
async with Telegram(url) as tweet: async with Telegram(url) as tweet:
if not tweet:
return
media = tweet.message_media_result() media = tweet.message_media_result()
if not media: if not media:
await update.effective_message.reply_text("No media found or media type is not supported.") await update.effective_message.reply_text(
"No media found or media type is not supported.",
reply_to_message_id=update.message.message_id,
)
return return
message_to_send = await update.effective_message.reply_media_group( message_to_send = await update.effective_message.reply_media_group(
media, media,
@@ -68,74 +82,104 @@ async def url_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not isinstance(message_to_send, tuple): if not isinstance(message_to_send, tuple):
message_to_send = (message_to_send,) message_to_send = (message_to_send,)
url = tweet.url url = tweet.url
if context.user_data.get('edit_before_forward', False): if context.chat_data.edit_before_forward:
message_reply = await update.effective_message.reply_text( message_reply = await update.effective_message.reply_text(
"Reply to edit message. [URL]", "Reply to edit message.",
reply_markup=InlineKeyboardMarkup.from_button( reply_markup=InlineKeyboardMarkup.from_column(
InlineKeyboardButton("↩️ Confirm", callback_data="forward") [InlineKeyboardButton(name, callback_data=f"template|{name}") for name in
context.chat_data.template.keys()] + [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.chat_data.edit_message[message_reply.id] = EditMessage(
context.user_data['message_to_send'] = message_to_send url=url,
context.user_data['message_url'] = url forward=message_to_send
)
return return
if 'forward_channel_id' in context.user_data: if context.chat_data.forward_channel_id:
await forward_message(update, context, message_to_send) await forward_message(update, context, message_to_send)
async def handel_url_media(update: Update, context: CustomContext) -> None:
url = update.message.text
logger.info(f"Receiving url: {url}")
await url_media(update, context, url)
async def forward_message( async def forward_message(
update: Update, update: Update,
context: ContextTypes.DEFAULT_TYPE, context: CustomContext,
message_to_send: tuple[Message, ...], message_to_send: tuple[Message, ...],
) -> None: ) -> None:
try: try:
await update.effective_chat.copy_messages( await update.effective_chat.copy_messages(
context.user_data['forward_channel_id'], context.chat_data.forward_channel_id,
[m.id for m in message_to_send] [m.id for m in message_to_send]
) )
except Exception as e: except Exception as e:
await update.effective_message.reply_text(str(e)) await update.effective_message.reply_text(str(e))
async def edit_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def edit_message(update: Update, context: CustomContext) -> bool:
if 'message_reply' not in context.user_data: if not (reply := update.message.reply_to_message):
return return False
if update.message.reply_to_message != context.user_data['message_reply']: _edit_message = context.chat_data.edit_message.get(reply.id, None)
return if not _edit_message:
template = context.user_data.get('template', None) return False
message_url = '<a href="{0}">{1}</a>' new_text = '<a href="{0}">{1}</a>'.format(
url = context.user_data['message_url'] _edit_message.url,
if template: html.escape(update.message.text)
update_text = template.replace("[]", message_url.format( )
url, update_text = context.chat_data.template[template].replace("[]", new_text) if (
html.escape(update.message.text) template := _edit_message.template) else new_text
)) await _edit_message.forward[0].edit_caption(update_text)
else: return True
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']
await message_to_send[0].edit_caption(update_text)
async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def handle_message(update: Update, context: CustomContext) -> None:
message_to_send = context.user_data['message_to_send'] if await edit_message(update, context):
await forward_message(update, context, message_to_send) return
if not (urls := extract_urls(update.message)):
return
for url in urls:
await url_media(update, context, url)
async def query_forward_message(update: Update, context: CustomContext) -> None:
_edit_message = context.chat_data.edit_message[update.effective_message.id]
await forward_message(update, context, _edit_message.forward)
await update.callback_query.answer('✅ Forwarded') await update.callback_query.answer('✅ Forwarded')
await update.callback_query.delete_message() await update.callback_query.delete_message()
del context.user_data['message_reply'] del _edit_message
del context.user_data['message_to_send']
del context.user_data['message_url']
async def query_template(update: Update, context: CustomContext) -> None:
query = update.callback_query
await query.answer()
name = query.data.split("|")[1]
_edit_message = context.chat_data.edit_message[query.message.message_id]
_edit_message.template = name
await _edit_message.forward[0].edit_caption(context.chat_data.template[name])
@send_action(ChatAction.TYPING) @send_action(ChatAction.TYPING)
async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, context: CustomContext) -> None:
await update.effective_message.reply_text(
"Welcome to the Twitter Fetcher Bot!\n"
"You can use this bot to fetch tweets from Twitter and forward them to a channel.\n"
"Use /set_forward_channel to set a channel to forward tweets.\n"
"Use /remove_forward_channel to remove the channel.\n"
"Use /edit_before_forward to enable or disable edit before forward.\n"
"Use /set_template to set a template for the forwarded message.\n"
"Use /bot_dict to see the bot's data.\n"
"Use /clear_edit_message to clear the edit message cache.\n"
"You can also reply to a message with a tweet URL to fetch the tweet and forward it to the channel.\n"
"You can also use inline query to search for tweets."
)
@send_action(ChatAction.TYPING)
async def cmd_set_forward_channel(update: Update, context: CustomContext) -> None:
if not context.args: if not context.args:
await update.effective_message.reply_text("Please provide a channel username or id.") await update.effective_message.reply_text("Please provide a channel username or id.")
return return
@@ -161,52 +205,58 @@ async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_
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:
context.user_data['forward_channel_id'] = channel.id context.chat_data.forward_channel_id = channel.id
await update.effective_message.reply_text("Add successfully.") await update.effective_message.reply_text("Add successfully.")
@send_action(ChatAction.TYPING) @send_action(ChatAction.TYPING)
async def cmd_remove_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_remove_forward_channel(update: Update, context: CustomContext) -> None:
if 'forward_channel_id' in context.user_data: if context.chat_data.forward_channel_id:
del context.user_data['forward_channel_id'] context.chat_data.forward_channel_id = None
await update.effective_message.reply_text("Remove successfully.") await update.effective_message.reply_text("Remove successfully.")
return return
await update.effective_message.reply_text("No channel to remove.") await update.effective_message.reply_text("No channel to remove.")
@send_action(ChatAction.TYPING) @send_action(ChatAction.TYPING)
async def cmd_edit_before_forward(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_edit_before_forward(update: Update, context: CustomContext) -> None:
if context.user_data.get('forward_channel_id', None) is None: if context.chat_data.forward_channel_id is None:
await update.effective_message.reply_text("Please enable forward channel first.") await update.effective_message.reply_text("Please enable forward channel first.")
return return
ebf_status = context.user_data.get('edit_before_forward', False) if context.chat_data.edit_before_forward:
if ebf_status: context.chat_data.edit_before_forward = False
context.user_data['edit_before_forward'] = False context.chat_data.edit_message.clear()
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.chat_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) @send_action(ChatAction.TYPING)
async def cmd_set_template(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_set_template(update: Update, context: CustomContext) -> None:
reply = update.effective_message.reply_to_message reply = update.effective_message.reply_to_message
if not reply: if not reply:
await update.effective_message.reply_text("Please reply to a message to set as template.") await update.effective_message.reply_text("Please reply to a message to set as template.")
return return
if '[]' not in reply.text_html: if '[]' not in (template := reply.text_html):
await update.effective_message.reply_text("Please reply to a message with [] to set as template.") await update.effective_message.reply_text("Please reply to a message with [] to set as template.")
return return
context.user_data['template'] = reply.text_html if not context.args:
await update.effective_message.reply_text("Please provide a name for the template.")
return
context.chat_data.template[''.join(context.args)] = template
await update.effective_message.reply_text("Template set.") await update.effective_message.reply_text("Template set.")
@send_action(ChatAction.TYPING) @send_action(ChatAction.TYPING)
async def cmd_user_dict(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_user_dict(update: Update, context: CustomContext) -> None:
await update.effective_message.reply_text(str(context.user_data)) await update.effective_message.reply_text(html.escape(str(context.chat_data)), disable_web_page_preview=True)
@send_action(ChatAction.TYPING)
async def cmd_clear_edit_message(update: Update, context: CustomContext) -> None:
context.chat_data.edit_message.clear()
await update.effective_message.reply_text("Edit message cleared.")
async def post_init(application: Application) -> None: async def post_init(application: Application) -> None:
@@ -223,7 +273,8 @@ async def post_init(application: Application) -> None:
async def post_stop(application: Application) -> None: async def post_stop(application: Application) -> None:
await application.bot.send_message(common.ADMIN[0], "Shutting down...") if common.ADMIN:
await application.bot.send_message(common.ADMIN[0], "Shutting down...")
async def post_shutdown(application: Application) -> None: async def post_shutdown(application: Application) -> None:
@@ -237,6 +288,7 @@ def main():
.token(common.BOT_TOKEN) .token(common.BOT_TOKEN)
.defaults(defaults) .defaults(defaults)
.persistence(persistence) .persistence(persistence)
.context_types(ContextTypes(context=CustomContext, chat_data=ChatData))
.post_init(post_init) .post_init(post_init)
.post_stop(post_stop) .post_stop(post_stop)
.post_shutdown(post_shutdown) .post_shutdown(post_shutdown)
@@ -250,15 +302,19 @@ def main():
handlers = [ handlers = [
InlineQueryHandler(inline_query), InlineQueryHandler(inline_query),
MessageHandler((filters.Regex(regex.x_url) | filters.Regex(regex.pixiv_url)) & filters.ChatType.PRIVATE, MessageHandler((filters.Regex(regex.x_url) | filters.Regex(regex.pixiv_url)) | filters.Regex(
url_media), regex.bsky_url) & filters.ChatType.PRIVATE,
handel_url_media),
CommandHandler("start", cmd_start),
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), CommandHandler("set_template", cmd_set_template),
MessageHandler(~filters.COMMAND & filters.ChatType.PRIVATE, edit_message), MessageHandler(~filters.COMMAND & filters.ChatType.PRIVATE, handle_message),
CallbackQueryHandler(query_forward_message, pattern="forward"), CallbackQueryHandler(query_forward_message, pattern="forward"),
CommandHandler("bot_dict", cmd_user_dict, filters=user_filter), CallbackQueryHandler(query_template, pattern=r"^template\|"),
CommandHandler("bot_dict", cmd_user_dict),
CommandHandler("clear_edit_message", cmd_clear_edit_message),
] ]
application.add_handlers(handlers) application.add_handlers(handlers)
+2 -2
View File
@@ -1,4 +1,4 @@
python-telegram-bot[webhooks]~=21.4 python-telegram-bot[webhooks]~=22.5
httpx[http2]~=0.27 httpx[http2]~=0.27
uvloop~=0.19.0; sys_platform != 'win32' uvloop~=0.22; sys_platform != 'win32'
async-pixiv @ git+https://github.com/TheFunny/async-pixiv@main async-pixiv @ git+https://github.com/TheFunny/async-pixiv@main
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
from functools import cached_property
from typing import TYPE_CHECKING
from utils.net import NetClient
from utils.regex import bsky_url
if TYPE_CHECKING:
from utils.types import BskyEmbedImages, BskyInfo, BskyEmbedVideo, BskyEmbedExternal
bsky_api_url = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread'
SENSITIVE_TAG = {'sexual', 'nudity', 'porn', 'graphic-media'}
class BskyMedia:
__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"BskyMedia(url={self.url}, thumb={self.thumb}, type={self.type})"
@property
def url(self) -> str:
return self._url
@property
def thumb(self) -> str:
return self._thumb
@property
def type(self) -> str:
return self._type
class Bsky:
__slots__ = ('_id', '_author', '_author_id', '_text', '_media', '_sensitive', '__dict__')
def __init__(
self,
id: str,
author: str,
author_id: str,
text: str,
media: list[BskyMedia],
sensitive: bool = False
):
self._id: str = id
self._author: str = author
self._author_id: str = author_id
self._text: str = text
self._media: list[BskyMedia] = media
self._sensitive: bool = sensitive
@property
def id(self) -> str:
return self._id
@cached_property
def url(self) -> str:
return f"https://bsky.app/profile/{self._author_id}/post/{self._id}"
@property
def author(self) -> str:
return self._author
@cached_property
def author_url(self) -> str:
return f"https://bsky.app/profile/{self._author_id}"
@property
def text(self) -> str:
return self._text
@property
def media(self) -> list[BskyMedia]:
return self._media
@property
def sensitive(self) -> bool:
return self._sensitive
class ProcessBsky:
__slots__ = ('_url', '_id', '_bsky')
def __init__(self, url: str):
self._url: str = url
async def __aenter__(self):
bsky = await self._fetch_bsky()
if not bsky['thread'].get('post'):
raise ValueError(f"BSky post not found: {bsky}")
self._bsky = bsky['thread']['post']
return Bsky(
id=self._id,
author=self._bsky['author']['displayName'],
author_id=self._bsky['author']['handle'],
text=self._bsky['record']['text'],
media=self._bsky_media,
sensitive=self._sensitive
)
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
async def _fetch_bsky(self) -> BskyInfo:
match = bsky_url.match(self._url)
if not match:
raise ValueError(f"Invalid Bsky URL: {self._url}")
auther_id, self._id = match.groups()
return await NetClient.fetch_json(
bsky_api_url,
params={'uri': f'at://{auther_id}/app.bsky.feed.post/{self._id}', 'depth': 0}
)
@property
def _bsky_media(self) -> list[BskyMedia]:
if not self._bsky.get('embed'):
return []
match (embed := self._bsky['embed'])['$type']:
case 'app.bsky.embed.images#view':
embed: BskyEmbedImages
return [
BskyMedia(
url=image['fullsize'],
thumb=image['thumb'],
media_type='image'
)
for image in embed['images']
]
case 'app.bsky.embed.video#view':
embed: BskyEmbedVideo
return [
BskyMedia(
url=embed['playlist'],
thumb=embed['thumbnail'],
media_type='video'
)
]
case 'app.bsky.embed.external#view':
embed: BskyEmbedExternal
return [
BskyMedia(
url=embed['external']['uri'],
thumb=embed['external']['thumb'],
media_type='external'
)
]
case _:
raise NotImplementedError(f"Unknown Bsky embed type: {embed['$type']}")
@property
def _sensitive(self) -> bool:
return any(
tag in label['val']
for label in self._bsky['labels']
for tag in SENSITIVE_TAG
)
+42
View File
@@ -0,0 +1,42 @@
import dataclasses
from typing import Optional
from telegram import Message
from telegram.ext import Application, CallbackContext, ExtBot
@dataclasses.dataclass(repr=False)
class EditMessage:
url: str
forward: tuple[Message, ...]
template: str = ""
def __str__(self):
forward = ", ".join(f"Message({f.id})" for f in self.forward)
return f"EditMessage(url={self.url}, forward={forward}, template={self.template})"
__repr__ = __str__
class ChatData:
def __init__(self):
self.forward_channel_id: Optional[int] = None
self.edit_before_forward: bool = False
self.edit_message: dict[int, EditMessage] = {}
self.template: dict[str, str] = {}
def __str__(self):
return f"ChatData(forward_channel_id={self.forward_channel_id}, edit_before_forward={self.edit_before_forward}, " \
f"edit_message={self.edit_message}, template={self.template})"
__repr__ = __str__
class CustomContext(CallbackContext[ExtBot, dict, ChatData, dict]):
def __init__(
self,
application: Application,
chat_id: Optional[int] = None,
user_id: Optional[int] = None
):
super().__init__(application=application, chat_id=chat_id, user_id=user_id)
+4 -4
View File
@@ -11,8 +11,8 @@ async def close_client(_client: AsyncClient) -> None:
return await _client.aclose() return await _client.aclose()
async def fetch_json(_client: AsyncClient, url: str) -> dict: async def fetch_json(_client: AsyncClient, url: str, params: dict = None) -> dict:
response = await _client.get(url) response = await _client.get(url, params=params)
assert response.is_success, f"Failed to fetch {url}, status code {response.status_code}" assert response.is_success, f"Failed to fetch {url}, status code {response.status_code}"
return response.json() return response.json()
@@ -33,5 +33,5 @@ class NetClient:
return cls._httpx_client return cls._httpx_client
@classmethod @classmethod
async def fetch_json(cls, url: str) -> dict: async def fetch_json(cls, url: str, params: dict = None) -> dict:
return await fetch_json(cls._httpx_client, url) return await fetch_json(cls._httpx_client, url, params)
+6 -78
View File
@@ -1,24 +1,12 @@
from __future__ import annotations from __future__ import annotations
import html from typing import Literal, TYPE_CHECKING
from typing import Generator, Literal, TYPE_CHECKING
from uuid import uuid4
from async_pixiv import PixivClient from async_pixiv import PixivClient
from async_pixiv.error import ApiError from async_pixiv.error import ApiError
from telegram import InlineQueryResultPhoto, InputMediaPhoto
from utils.logger import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from async_pixiv.model.illust import Illust 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: class PixivMedia:
@@ -110,11 +98,9 @@ class Pixiv:
] ]
class ProcessPixiv: class _ProcessPixiv:
_client: PixivClient _client: PixivClient
__slots__ = ('_url', '_illust')
@classmethod @classmethod
async def init_client(cls, token: str) -> None: async def init_client(cls, token: str) -> None:
cls._client = PixivClient() cls._client = PixivClient()
@@ -129,6 +115,10 @@ class ProcessPixiv:
async def refresh_token(cls): async def refresh_token(cls):
await cls._client.login_with_token(cls._token) await cls._client.login_with_token(cls._token)
class ProcessPixiv(_ProcessPixiv):
__slots__ = ('_url', '_illust')
def __init__(self, url: str): def __init__(self, url: str):
self._url: str = url self._url: str = url
@@ -149,65 +139,3 @@ class ProcessPixiv:
def _parse_illust_id(self) -> int: def _parse_illust_id(self) -> int:
return int(self._url.split("/")[-1]) 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
@property
def url(self) -> str:
return self._pixiv.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
+4 -1
View File
@@ -1,8 +1,11 @@
import re import re
x_url = re.compile(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter)\.com/(.+)/status/(\d+)") x_url = re.compile(
r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter|fixupx|fxtwitter)\.com/(.+)/status/(\d+)")
x_media_url = re.compile(r"^(?:https?://)?(pbs|video)\.twimg\.com/(.*)") x_media_url = re.compile(r"^(?:https?://)?(pbs|video)\.twimg\.com/(.*)")
x_tco_url = re.compile(r"(?:https?://)?t\.co/.+$", re.M) x_tco_url = re.compile(r"(?:https?://)?t\.co/.+$", re.M)
message_url = re.compile(r"\[.+]", re.S) message_url = re.compile(r"\[.+]", re.S)
pixiv_url = re.compile(r"^(?:https?://)?(?:www\.)?pixiv\.net/(?:en/)?(?:artworks/|i/)(\d+)") pixiv_url = re.compile(r"^(?:https?://)?(?:www\.)?pixiv\.net/(?:en/)?(?:artworks/|i/)(\d+)")
bsky_url = re.compile(r"^(?:https?://)?bsky\.app/profile/(.+)/post/(.+)")
+267 -3
View File
@@ -1,9 +1,32 @@
from __future__ import annotations from __future__ import annotations
import html
from functools import cached_property
from typing import Generator, TYPE_CHECKING
from uuid import uuid4
from telegram import InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto, \
InputMediaVideo
from common import PIXIV_REFRESH_TOKEN from common import PIXIV_REFRESH_TOKEN
from .pixiv import TelegramPixiv from .bsky import ProcessBsky
from .regex import pixiv_url, x_url from .logger import get_logger
from .tweet import TelegramTweet from .pixiv import ProcessPixiv
from .regex import bsky_url, pixiv_url, x_url
from .tweet import ProcessTweet
if TYPE_CHECKING:
from .types import TypeInlineQueryResult, TypeMessageMediaResult
logger = get_logger(__name__)
message_raw_text_tweet = """{url}
<a href="{author_url}">{author}</a>: {text}
"""
message_raw_text_pixiv = """<a href="{url}">{text}</a> / <a href="{author_url}">{author}</a>
{tags}
"""
class Telegram: class Telegram:
@@ -17,8 +40,249 @@ class Telegram:
elif PIXIV_REFRESH_TOKEN and pixiv_url.match(self._url): elif PIXIV_REFRESH_TOKEN and pixiv_url.match(self._url):
async with TelegramPixiv(self._url) as pixiv: async with TelegramPixiv(self._url) as pixiv:
return pixiv return pixiv
elif bsky_url.match(self._url):
async with TelegramBsky(self._url) as bsky:
return bsky
else: else:
return None # TODO add raise and catch return None # TODO add raise and catch
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
pass pass
class TelegramTweet:
message_raw_text = message_raw_text_tweet
__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
@property
def url(self) -> str:
return self._tweet.url
@cached_property
def message_text(self) -> str:
tweet = self._tweet
return self.message_raw_text.format(
url=tweet.url,
author_url=tweet.author_url,
author=html.escape(tweet.author),
text=html.escape(tweet.text)
)
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]:
tweet = self._tweet
for tweet_media in tweet.media:
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InlineQueryResultPhoto(
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(uuid4()),
video_url=tweet_media.url,
mime_type="video/mp4",
thumbnail_url=tweet_media.thumb,
title=tweet.text,
caption=self.message_text
)
elif tweet_media.type == "gif":
yield InlineQueryResultMpeg4Gif(
id=str(uuid4()),
mpeg4_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text
)
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=tweet.sensitive
)
elif tweet_media.type == "video":
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb
)
elif tweet_media.type == "gif":
if len(tweet.media) == 1:
yield tweet_media.url, tweet.sensitive
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb
)
class TelegramPixiv:
message_raw_text = message_raw_text_pixiv
__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
@property
def url(self) -> str:
return self._pixiv.url
@property
def message_text(self) -> str:
pixiv = self._pixiv
return self.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(i for i in self.inline_query_generator() if i)
def message_media_result(self) -> tuple[TypeMessageMediaResult, ...]:
return tuple(i for i in self.message_media_generator() if i)
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
class TelegramBsky:
message_raw_text = message_raw_text_tweet
__slots__ = ('_url', '_bsky', '__dict__')
def __init__(self, url: str):
self._url: str = url
async def __aenter__(self):
async with ProcessBsky(self._url) as bsky:
self._bsky = bsky
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@property
def url(self) -> str:
return self._bsky.url
@cached_property
def message_text(self) -> str:
bsky = self._bsky
return self.message_raw_text.format(
url=bsky.url,
author_url=bsky.author_url,
author=html.escape(bsky.author),
text=html.escape(bsky.text)
)
def inline_query_result(self) -> tuple[TypeInlineQueryResult, ...]:
return tuple(i for i in self.inline_query_generator() if i)
def message_media_result(self) -> tuple[TypeMessageMediaResult, ...]:
return tuple(i for i in self.message_media_generator() if i)
def inline_query_generator(self) -> Generator[TypeInlineQueryResult, None, None]:
bsky = self._bsky
for bsky_media in bsky.media:
logger.info(str(bsky_media))
if bsky_media.type == "image":
yield InlineQueryResultPhoto(
id=str(uuid4()),
photo_url=bsky_media.url,
thumbnail_url=bsky_media.thumb,
caption=self.message_text
)
elif bsky_media.type == "video":
# yield InlineQueryResultVideo(
# id=str(uuid4()),
# video_url=bsky_media.url,
# mime_type="video/mp4",
# thumbnail_url=bsky_media.thumb,
# title=bsky.text,
# caption=self.message_text
# )
yield
elif bsky_media.type == "external":
# yield InlineQueryResultVideo(
# id=str(uuid4()),
# video_url=bsky_media.url,
# mime_type="image/gif",
# thumbnail_url=bsky_media.thumb,
# title=bsky.text,
# caption=self.message_text
# )
yield
def message_media_generator(self) -> Generator[TypeMessageMediaResult, None, None]:
bsky = self._bsky
for bsky_media in bsky.media:
logger.info(str(bsky_media))
if bsky_media.type == "image":
yield InputMediaPhoto(
media=bsky_media.url,
has_spoiler=bsky.sensitive
)
elif bsky_media.type == "video":
# yield InputMediaVideo(
# media=bsky_media.url,
# has_spoiler=bsky.sensitive,
# thumbnail=bsky_media.thumb
# )
yield
elif bsky_media.type == "external":
yield bsky_media.url, bsky.sensitive
+5 -103
View File
@@ -1,30 +1,17 @@
from __future__ import annotations from __future__ import annotations
import html
from functools import cached_property from functools import cached_property
from typing import Generator, TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import uuid4
from telegram import (InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto,
InputMediaVideo)
from .logger import get_logger
from .net import NetClient from .net import NetClient
from .regex import x_media_url, x_tco_url, x_url from .regex import x_media_url, x_tco_url, x_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .types import TweetInfo, TypeInlineQueryResult, TypeMessageMediaResult from .types import TweetInfo
logger = get_logger(__name__)
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}'
message_raw_text = """{url}
<a href="{author_url}">{author}</a>: {text}
"""
class TweetMedia: class TweetMedia:
__slots__ = ('_url', '_thumb', '_type', '__dict__') __slots__ = ('_url', '_thumb', '_type', '__dict__')
@@ -97,7 +84,7 @@ class Tweet:
@cached_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://x.com/{self._author_id}/status/{self._id}"
@property @property
def author(self) -> str: def author(self) -> str:
@@ -105,7 +92,7 @@ class Tweet:
@cached_property @cached_property
def author_url(self) -> str: def author_url(self) -> str:
return f"https://twitter.com/{self._author_id}" return f"https://x.com/{self._author_id}"
@property @property
def text(self) -> str: def text(self) -> str:
@@ -149,7 +136,7 @@ class ProcessTweet:
@property @property
def _tweet_text(self) -> str: def _tweet_text(self) -> str:
match = x_tco_url.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]:
@@ -163,88 +150,3 @@ class ProcessTweet:
] ]
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:
tweet = self._tweet
return message_raw_text.format(
url=tweet.url,
author_url=tweet.author_url,
author=html.escape(tweet.author),
text=html.escape(tweet.text)
)
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]:
tweet = self._tweet
for tweet_media in tweet.media:
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InlineQueryResultPhoto(
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(uuid4()),
video_url=tweet_media.url,
mime_type="video/mp4",
thumbnail_url=tweet_media.thumb,
title=tweet.text,
caption=self.message_text
)
elif tweet_media.type == "gif":
yield InlineQueryResultMpeg4Gif(
id=str(uuid4()),
mpeg4_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text
)
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=tweet.sensitive
)
elif tweet_media.type == "video":
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb
)
elif tweet_media.type == "gif":
if len(tweet.media) == 1:
yield tweet_media.url, tweet.sensitive
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=tweet.sensitive,
thumbnail=tweet_media.thumb
)
+59 -1
View File
@@ -1,4 +1,6 @@
from typing import TypedDict from __future__ import annotations
from typing import Literal, TypedDict
from telegram import InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto, \ from telegram import InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto, \
InputMediaVideo InputMediaVideo
@@ -15,3 +17,59 @@ class TweetInfo(TypedDict):
text: str text: str
media_extended: list[dict] media_extended: list[dict]
possibly_sensitive: bool possibly_sensitive: bool
class BskyInfo(TypedDict):
thread: BskyThread
class BskyThread(TypedDict):
post: BskyPost
class BskyPost(TypedDict):
author: BskyAuthor
record: BskyPostRecord
embed: BskyEmbedImages | BskyEmbedVideo | BskyEmbedExternal
labels: list[BskyLabel]
class BskyAuthor(TypedDict):
handle: str
displayName: str
class BskyPostRecord(TypedDict):
text: str
class BskyLabel(TypedDict):
val: str
class BskyEmbedImage(TypedDict):
thumb: str
fullsize: str
BskyEmbedImages = TypedDict('BskyEmbedImages', {
'$type': Literal['app.bsky.embed.images#view'],
'images': list[BskyEmbedImage]
})
BskyEmbedVideo = TypedDict('BskyEmbedVideo', {
'$type': Literal['app.bsky.embed.video#view'],
'playlist': str,
'thumbnail': str
})
class BskyEmbedExternalItem(TypedDict):
uri: str
thumb: str
BskyEmbedExternal = TypedDict('BskyEmbedExternal', {
'$type': Literal['app.bsky.embed.external#view'],
'external': BskyEmbedExternalItem
})