diff --git a/config.gen.ini b/config.gen.ini index 9a649cc..6d0faf7 100644 --- a/config.gen.ini +++ b/config.gen.ini @@ -26,3 +26,7 @@ bili_cookie = ABCD bili_auth_user = 777000,111000 bili_auth_chat = 777000,111000 mys_cookie = ABCD + +[bsky] +username = 111 +password = 11 diff --git a/defs/bsky.py b/defs/bsky.py new file mode 100644 index 0000000..a2edfb7 --- /dev/null +++ b/defs/bsky.py @@ -0,0 +1,188 @@ +import asyncio +import traceback +from typing import Optional, TYPE_CHECKING + +from atproto_client.exceptions import BadRequestError +from pyrogram.enums import ParseMode +from pyrogram.errors import FloodWait +from pyrogram.types import ( + InlineKeyboardMarkup, + InlineKeyboardButton, + InputMediaPhoto, + Message, +) + +from init import bot, logs +from models.models.bsky import HumanPost, HumanAuthor +from models.services.bsky import bsky_client + +if TYPE_CHECKING: + from modules.bsky_api import Reply + + +def flood_wait(): + def decorator(function): + async def wrapper(*args, **kwargs): + try: + return await function(*args, **kwargs) + except FloodWait as e: + logs.warning(f"遇到 FloodWait,等待 {e.value} 秒后重试!") + await asyncio.sleep(e.value + 1) + return await wrapper(*args, **kwargs) + except Exception as e: + traceback.print_exc() + raise e + + return wrapper + + return decorator + + +class Timeline: + @staticmethod + def get_button(post: HumanPost) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("Source", url=post.url), + InlineKeyboardButton("Author", url=post.author.url), + ] + ] + ) + + @staticmethod + def get_author_button(author: HumanAuthor) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("Author", url=author.url), + ] + ] + ) + + @staticmethod + def get_media_group(text: str, post: HumanPost) -> list[InputMediaPhoto]: + data = [] + images = post.images + for idx, image in enumerate(images): + data.append( + InputMediaPhoto( + image, + caption=text if idx == 0 else None, + parse_mode=ParseMode.HTML, + ) + ) + return data + + @staticmethod + def get_post_text(post: HumanPost) -> str: + text = "Bsky Post Info\n\n" + text += post.content + key = "发表" + if post.is_reply: + key = "回复" + elif post.is_quote: + key = "引用" + text += f"\n\n{post.author.format} {key}于 {post.time_str}" + return text + + @staticmethod + @flood_wait() + async def send_to_user(reply: "Reply", post: HumanPost): + text = Timeline.get_post_text(post) + if post.gif: + return await bot.send_animation( + reply.cid, + post.gif, + caption=text, + reply_to_message_id=reply.mid, + parse_mode=ParseMode.HTML, + reply_markup=Timeline.get_button(post), + ) + elif not post.images: + return await bot.send_message( + reply.cid, + text, + disable_web_page_preview=True, + reply_to_message_id=reply.mid, + parse_mode=ParseMode.HTML, + reply_markup=Timeline.get_button(post), + ) + elif len(post.images) == 1: + return await bot.send_photo( + reply.cid, + post.images[0], + caption=text, + reply_to_message_id=reply.mid, + parse_mode=ParseMode.HTML, + reply_markup=Timeline.get_button(post), + ) + else: + await bot.send_media_group( + reply.cid, + Timeline.get_media_group(text, post), + reply_to_message_id=reply.mid, + ) + + @staticmethod + def code(text: str) -> str: + return f"{text}" + + @staticmethod + def get_author_text(author: HumanAuthor) -> str: + text = "Bsky User Info\n\n" + text += f"Name: {Timeline.code(author.display_name)}\n" + text += f"Username: {author.format_handle}\n" + if author.description: + text += f"Bio: {Timeline.code(author.description)}\n" + text += f"Joined: {Timeline.code(author.time_str)}\n" + if author.posts_count: + text += f"📤 {author.posts_count} " + if author.followers_count: + text += f"粉丝 {author.followers_count} " + if author.follows_count: + text += f"关注 {author.follows_count}" + return text + + @staticmethod + @flood_wait() + async def send_user(reply: "Reply", author: HumanAuthor): + text = Timeline.get_author_text(author) + if author.avatar_img: + return await bot.send_photo( + reply.cid, + author.avatar_img, + caption=text, + reply_to_message_id=reply.mid, + parse_mode=ParseMode.HTML, + reply_markup=Timeline.get_author_button(author), + ) + else: + return await bot.send_message( + reply.cid, + text, + disable_web_page_preview=True, + reply_to_message_id=reply.mid, + parse_mode=ParseMode.HTML, + reply_markup=Timeline.get_author_button(author), + ) + + @staticmethod + async def fetch_post(handle: str, cid: str) -> Optional[HumanPost]: + try: + user = await Timeline.fetch_user(handle) + uri = f"at://{user.did}/app.bsky.feed.post/{cid}" + post = await bsky_client.client.get_post_thread(uri) + return HumanPost.parse_thread(post.thread) + except BadRequestError as e: + logs.error(f"bsky Error: {e}") + return None + + @staticmethod + async def fetch_user(handle: str) -> Optional[HumanAuthor]: + try: + user = await bsky_client.client.get_profile(handle) + return HumanAuthor.parse_detail(user) + except BadRequestError as e: + logs.error(f"bsky Error: {e}") + return None diff --git a/defs/glover.py b/defs/glover.py index ce67615..81b5b9a 100644 --- a/defs/glover.py +++ b/defs/glover.py @@ -48,6 +48,9 @@ try: except ValueError: bili_auth_user: List[int] = [] bili_auth_chat: List[int] = [] +# bsky +bsky_username = config.get("bsky", "username", fallback="") +bsky_password = config.get("bsky", "password", fallback="") try: ipv6 = bool(strtobool(ipv6)) except ValueError: diff --git a/main.py b/main.py index e3b9155..2a6f313 100644 --- a/main.py +++ b/main.py @@ -3,12 +3,14 @@ import asyncio from pyrogram import idle from init import logs, bot, sqlite +from models.services.bsky import bsky_client async def main(): logs.info("连接服务器中。。。") await bot.start() bot.loop.create_task(sqlite.create_db_and_tables()) + bot.loop.create_task(bsky_client.initialize()) logs.info(f"@{bot.me.username} 运行成功!") await idle() await bot.stop() diff --git a/models/models/bsky.py b/models/models/bsky.py new file mode 100644 index 0000000..20c9533 --- /dev/null +++ b/models/models/bsky.py @@ -0,0 +1,198 @@ +from typing import TYPE_CHECKING, Optional, Union + +from datetime import datetime + +import pytz +from pydantic import BaseModel + +from atproto_client.models.app.bsky.embed.images import View as BskyViewImage +from atproto_client.models.app.bsky.embed.video import View as BskyViewVideo +from atproto_client.models.app.bsky.embed.external import View as BskyViewExternal +from atproto_client.models.app.bsky.embed.record import ( + View as BskyViewRecord, + ViewRecord as BskyViewRecordRecord, +) + +if TYPE_CHECKING: + from atproto_client.models.app.bsky.feed.defs import ( + FeedViewPost, + PostView, + ThreadViewPost, + ) + from atproto_client.models.app.bsky.actor.defs import ( + ProfileViewBasic, + ProfileViewDetailed, + ) + +TZ = pytz.timezone("Asia/Shanghai") + + +class HumanAuthor(BaseModel): + display_name: str + handle: str + did: str + avatar_img: str + created_at: datetime + + description: Optional[str] = None + followers_count: Optional[int] = None + follows_count: Optional[int] = None + posts_count: Optional[int] = None + + @property + def url(self) -> str: + return f"https://bsky.app/profile/{self.handle}" + + @property + def format(self) -> str: + return f'{self.display_name}' + + @property + def format_handle(self) -> str: + return f'@{self.handle}' + + @property + def time_str(self) -> str: + # utc+8 + return self.created_at.astimezone(TZ).strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def parse(author: "ProfileViewBasic") -> "HumanAuthor": + return HumanAuthor( + display_name=author.display_name, + handle=author.handle, + did=author.did, + avatar_img=author.avatar, + created_at=author.created_at, + ) + + @staticmethod + def parse_detail(author: "ProfileViewDetailed") -> "HumanAuthor": + return HumanAuthor( + display_name=author.display_name, + handle=author.handle, + did=author.did, + avatar_img=author.avatar, + created_at=author.created_at, + description=author.description, + followers_count=author.followers_count, + follows_count=author.follows_count, + posts_count=author.posts_count, + ) + + +class HumanPost(BaseModel, frozen=False): + cid: str + content: str + images: Optional[list[str]] = None + gif: Optional[str] = None + video: Optional[str] = None + external: Optional[str] = None + created_at: datetime + + like_count: int + quote_count: int + reply_count: int + repost_count: int + + uri: str + + author: HumanAuthor + + is_quote: bool = False + is_reply: bool = False + is_repost: bool = False + + parent_post: "HumanPost" = None + + @property + def url(self) -> str: + return self.author.url + "/post/" + self.uri.split("/")[-1] + + @property + def time_str(self) -> str: + # utc+8 + return self.created_at.astimezone(TZ).strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def parse_view(post: Union["PostView", "BskyViewRecordRecord"]) -> "HumanPost": + record = post.value if isinstance(post, BskyViewRecordRecord) else post.record + embed = ( + (post.embeds[0] if post.embeds else None) + if isinstance(post, BskyViewRecordRecord) + else post.embed + ) + content = record.text + created_at = record.created_at + # images + images = [] + if isinstance(embed, BskyViewImage): + for image in embed.images: + images.append(image.fullsize) + # video + video = None + if isinstance(embed, BskyViewVideo): + video = embed.playlist # m3u8 + # gif + gif, extra = None, None + if isinstance(embed, BskyViewExternal): + uri = embed.external.uri + if ".gif" in uri: + gif = uri + else: + extra = uri + # author + author = HumanAuthor.parse(post.author) + return HumanPost( + cid=post.cid, + content=content, + images=images, + gif=gif, + video=video, + external=extra, + created_at=created_at, + like_count=post.like_count, + quote_count=post.quote_count, + reply_count=post.reply_count, + repost_count=post.repost_count, + uri=post.uri, + author=author, + ) + + @staticmethod + def parse(data: "FeedViewPost") -> "HumanPost": + base = HumanPost.parse_view(data.post) + is_quote, is_reply, is_repost = False, False, False + parent_post = None + if data.reply: + is_reply = True + parent_post = HumanPost.parse_view(data.reply.parent) + elif data.reason: + is_repost = True + elif data.post.embed and isinstance(data.post.embed, BskyViewRecord): + is_quote = True + if isinstance(data.post.embed.record, BskyViewRecordRecord): + parent_post = HumanPost.parse_view(data.post.embed.record) + base.is_quote = is_quote + base.is_reply = is_reply + base.is_repost = is_repost + base.parent_post = parent_post + return base + + @staticmethod + def parse_thread(data: "ThreadViewPost") -> "HumanPost": + base = HumanPost.parse_view(data.post) + is_quote, is_reply, is_repost = False, False, False + parent_post = None + if data.parent: + is_reply = True + parent_post = HumanPost.parse_view(data.parent.post) + elif data.post.embed and isinstance(data.post.embed, BskyViewRecord): + is_quote = True + if isinstance(data.post.embed.record, BskyViewRecordRecord): + parent_post = HumanPost.parse_view(data.post.embed.record) + base.is_quote = is_quote + base.is_reply = is_reply + base.is_repost = is_repost + base.parent_post = parent_post + return base diff --git a/models/services/bsky.py b/models/services/bsky.py new file mode 100644 index 0000000..1f4c20a --- /dev/null +++ b/models/services/bsky.py @@ -0,0 +1,60 @@ +from pathlib import Path + +from atproto import AsyncClient +from atproto.exceptions import BadRequestError + +from typing import Optional + +from atproto_client import Session, SessionEvent + +from defs.glover import bsky_username, bsky_password +from init import logs + +DATA_PATH = Path("data") + + +class SessionReuse: + def __init__(self): + self.session_file = DATA_PATH / "session.txt" + + def get_session(self) -> Optional[str]: + try: + with open(self.session_file, encoding="UTF-8") as f: + return f.read() + except FileNotFoundError: + return None + + def save_session(self, session_str) -> None: + with open(self.session_file, "w", encoding="UTF-8") as f: + f.write(session_str) + + async def on_session_change(self, event: SessionEvent, session: Session) -> None: + if event in (SessionEvent.CREATE, SessionEvent.REFRESH): + self.save_session(session.export()) + + +class BskyClient: + def __init__(self): + self.client = AsyncClient() + self.session = SessionReuse() + self.client.on_session_change(self.session.on_session_change) + + async def initialize(self): + session = self.session.get_session() + if session: + try: + await self.client.login(session_string=session) + logs.info( + "[bsky] Login with session success, me: %s", self.client.me.handle + ) + return + except BadRequestError: + pass + await self.client.login(bsky_username, bsky_password) + logs.info( + "[bsky] Login with username and password success, me: %s", + self.client.me.handle, + ) + + +bsky_client = BskyClient() diff --git a/modules/bsky_api.py b/modules/bsky_api.py new file mode 100644 index 0000000..ae1416d --- /dev/null +++ b/modules/bsky_api.py @@ -0,0 +1,71 @@ +import contextlib +from typing import Optional +from urllib.parse import urlparse + +from pydantic import BaseModel +from pyrogram import Client, filters, ContinuePropagation +from pyrogram.enums import MessageEntityType, ChatType +from pyrogram.types import Message + +from defs.bsky import Timeline +from init import bot + + +class Reply(BaseModel): + cid: int + mid: Optional[int] = None + + +async def process_url(url: str, reply: Reply): + url = urlparse(url) + if url.hostname and url.hostname in ["bsky.app"]: + if url.path.find("profile") < 0: + return + author_handle = str(url.path[url.path.find("profile") + 8 :].split("/")[0]) + if url.path.find("post") >= 0: + status_id = str(url.path[url.path.find("post") + 5 :].split("/")[0]).split( + "?" + )[0] + try: + post = await Timeline.fetch_post(author_handle, status_id) + await Timeline.send_to_user(reply, post) + except Exception as e: + print(e) + elif url.path == "/": + return + else: + # 解析用户 + try: + user = await Timeline.fetch_user(author_handle) + await Timeline.send_user(reply, user) + except Exception as e: + print(e) + + +@bot.on_message(filters.incoming & filters.text & filters.regex(r"bsky.app/")) +async def bsky_share(_: Client, message: Message): + if not message.text: + return + if ( + message.sender_chat + and message.forward_from_chat + and message.sender_chat.id == message.forward_from_chat.id + ): + # 过滤绑定频道的转发 + return + mid = message.id + if message.text.startswith("del") and message.chat.type == ChatType.CHANNEL: + with contextlib.suppress(Exception): + await message.delete() + mid = None + reply = Reply(cid=message.chat.id, mid=mid) + for num in range(len(message.entities)): + entity = message.entities[num] + if entity.type == MessageEntityType.URL: + url = message.text[entity.offset : entity.offset + entity.length] + elif entity.type == MessageEntityType.TEXT_LINK: + url = entity.url + else: + continue + await process_url(url, reply) + raise ContinuePropagation diff --git a/requirements.txt b/requirements.txt index 6a49c83..ec499c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ aiosqlite aiofiles pydantic lottie +atproto