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