Repository: softrebel/pybsky (language: python)

Issue:
Package structure



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 68bc17f..aeb41de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -158,3 +158,6 @@ cython_debug/
 #  and can be added to the global gitignore or merged into this file.  For a more nuclear
 #  option (not recommended) you can uncomment the following to ignore the entire idea folder.
 #.idea/
+
+run_test.py
+.vscode
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..06181d2
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,12 @@
+repos:
+- repo: https://github.com/astral-sh/ruff-pre-commit
+  # Ruff version.
+  rev: v0.0.280
+  hooks:
+    - id: ruff
+
+- repo: https://github.com/ambv/black
+  rev: stable
+  hooks:
+  - id: black
+    language_version: python3.11
diff --git a/LICENSE b/LICENSE
index 7502eaf..ad027c6 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2023 Mohammadreza Shaghouzi
+Copyright (c) 2023 Mohammadreza softrebel
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 4d3be02..c89a5b2 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,26 @@
-# pybsky
-Python BlueSky client
+# Pybsky
 
+<div align='center'>
 
-## Todos:
+![./a](./assets/cover.png)
 
- - Create Client
- - Add Login logic
- - Get Profile logic
- - Get Follwer/Following logic
- - Follow/Unfollow
- - Get Likers
- - Get Commenters
- - Get Feed/Timeline
- - Post skeets
+</div>
+
+
+## TODO List:
+
+- [x] Create Client
+- [x] Add Login logic
+- [ ] Get Profile logic
+- [ ] Get Follwer/Following logic
+- [ ] Follow/Unfollow
+- [ ] Get Likers
+- [ ] Get Commenters
+- [ ] Get Feed/Timeline
+- [ ] Post skeets
+
+## License
+
+[![License](http://img.shields.io/:license-mit-blue.svg?style=flat-square)](http://badges.mit-license.org)
+
+- This project is licensed under the terms of the **[MIT license](LICENSE)**
diff --git a/__init__.py b/__init__.py
index e69de29..eb74202 100644
--- a/__init__.py
+++ b/__init__.py
@@ -0,0 +1,1 @@
+from .pybsky import *  # noqa: F403
diff --git a/assets/cover.png b/assets/cover.png
new file mode 100644
index 0000000..21a2c0f
Binary files /dev/null and b/assets/cover.png differ
diff --git a/client.py b/client.py
deleted file mode 100644
index d811648..0000000
--- a/client.py
+++ /dev/null
@@ -1,21 +0,0 @@
-
-import requests
-from .mixins.login import LoginMixin
-
-
-class Client(LoginMixin):
-    def __init__(
-        self,
-
-        proxies=None,
-        **kwargs
-    ):
-        super().__init__(**kwargs)
-        
-        self.session = requests.Session()
-        self.session.timeout = 20
-
-        self.proxies = None
-        if proxies:
-            self.proxies = proxies
-            self.session.proxies = proxies
diff --git a/configs/__init__.py b/configs/__init__.py
deleted file mode 100644
index 4651c7f..0000000
--- a/configs/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from user_agents import USER_AGENTS
diff --git a/configs/base.py b/configs/base.py
deleted file mode 100644
index 39a18b7..0000000
--- a/configs/base.py
+++ /dev/null
@@ -1,1 +0,0 @@
-BSKY_BASE_URL='https://bsky.social/xrpc/'
diff --git a/configs/exceptions.py b/configs/exceptions.py
deleted file mode 100644
index 274e8cf..0000000
--- a/configs/exceptions.py
+++ /dev/null
@@ -1,5 +0,0 @@
-class AuthenticationRequired(Exception):
-    """Invalid identifier(username.bsky.social) or password"""
-
-class UnknownResponse(Exception):
-    """Unknown Response"""
\ No newline at end of file
diff --git a/configs/log.py b/configs/log.py
deleted file mode 100644
index e69de29..0000000
diff --git a/configs/user_agents.py b/configs/user_agents.py
deleted file mode 100644
index c421abc..0000000
--- a/configs/user_agents.py
+++ /dev/null
@@ -1,45 +0,0 @@
-USER_AGENTS = [
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1",
-    "Mozilla/5.0 (Linux; U; Android 2.2) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
-    "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.71 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36",
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1",
-    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15",
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.83 Safari/537.1",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36",
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)",
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18363",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36",
-    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36",
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Mobile/15E148 Safari/604.1",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:94.0) Gecko/20100101 Firefox/94.0",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 5.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36",
-    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15",
-    "Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36",
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36",
-    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/605.1.15 (KHTML, like Gecko)",
-    "Mozilla/5.0 (iPad; CPU OS 9_3_5 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13G36",
-    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Safari/605.1.15",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36",
-    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36",
-    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15",
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
-]
\ No newline at end of file
diff --git a/mixins/__init__.py b/mixins/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/mixins/login.py b/mixins/login.py
deleted file mode 100644
index 9cd95ae..0000000
--- a/mixins/login.py
+++ /dev/null
@@ -1,49 +0,0 @@
-
-import random
-import requests
-
-from ..configs import USER_AGENTS
-from ..configs.exceptions import AuthenticationRequired , UnknownResponse
-
-
-class LoginMixin():
-
-    def login(self , username:str , password:str):
-
-        session = requests.Session()
-        session.timeout = 30
-        session.proxies = self.session.proxies
-
-        self.user_agent = random.choice(USER_AGENTS)
-
-
-        headers = {
-            'origin': 'https://bsky.app',
-            'referer': 'https://bsky.app/',
-            'sec-fetch-dest': 'empty',
-            'sec-fetch-mode': 'cors',
-            'sec-fetch-site': 'cross-site',
-            'user-agent': self.user_agent,
-        }
-
-        payload = {
-            'identifier': f'{username}.bsky.social',
-            'password': password,
-        }
-        url = 'https://bsky.social/xrpc/com.atproto.server.createSession'
-        response = requests.post(url, headers=headers, json=payload)
-
-        if response.status_code == 200 :
-            self.access_jwt = response.json()["accessJwt"]
-            self.refresh_jwt = response.json()["refreshJwt"]
-            return True
-        elif response.status_code == 401 :
-            raise AuthenticationRequired("Invalid identifier(username.bsky.social) or password")
-        else:
-            raise UnknownResponse(f"status code => {response.status_code} , response => {response.text}")
-        #TODO => handle all type of response
-
-
-
-
-        
\ No newline at end of file
diff --git a/pybsky/__init__.py b/pybsky/__init__.py
new file mode 100644
index 0000000..3493c32
--- /dev/null
+++ b/pybsky/__init__.py
@@ -0,0 +1,3 @@
+from .client import *
+from .core import *
+from .mixins import *
diff --git a/pybsky/client.py b/pybsky/client.py
new file mode 100644
index 0000000..9431af2
--- /dev/null
+++ b/pybsky/client.py
@@ -0,0 +1,113 @@
+import requests
+from .mixins import (
+    LoginMixin,
+    FeedMixin,
+    GraphMixin,
+    ProfileMixin,
+    ServerMixin,
+    PostMixin,
+    RepoMixin,
+)
+from .core import (
+    USER_AGENTS,
+    SERVER_URL,
+    CREATE_RECORD_URL,
+    BSKY_BASE_URL,
+    DELETE_RECORD_URL,
+)
+import random
+from datetime import datetime, timezone
+
+
+class Client(
+    LoginMixin, FeedMixin, GraphMixin, ProfileMixin, ServerMixin, PostMixin, RepoMixin
+):
+    server: str = None
+    proxies: str = None
+    access_jwt: str = None
+    refresh_jwt: str = None
+    user_agent: str = None
+    session: requests.Session = None
+    own_profile: dict = None
+
+    def __init__(self, proxies=None, server=None, **kwargs):
+        super().__init__(**kwargs)
+
+        self.server = server if server else SERVER_URL
+        self.session = requests.Session()
+        self.session.timeout = 20
+        self.user_agent = random.choice(USER_AGENTS)
+
+        self.proxies = None
+        if proxies:
+            self.proxies = proxies
+            self.session.proxies = proxies
+
+        headers = {
+            "origin": "https://bsky.app",
+            "referer": "https://bsky.app/",
+            "sec-fetch-dest": "empty",
+            "sec-fetch-mode": "cors",
+            "sec-fetch-site": "cross-site",
+            "user-agent": self.user_agent,
+        }
+        self.session.headers.update(headers)
+
+    @property
+    def did(self):
+        if not self.own_profile or "did" not in self.own_profile.keys():
+            return None
+        return self.own_profile["did"]
+
+    @property
+    def handle(self):
+        if not self.own_profile or "handle" not in self.own_profile.keys():
+            return None
+        return self.own_profile["handle"]
+
+    def get_actor(self, username: str) -> str:
+        return username
+        # if 'did' in username:
+        #     return username
+        # if self.server in username:
+        #     return username
+        # else:
+        #     return f"{username}.{self.server}"
+
+    def send_request(
+        self,
+        method: str,
+        url: str,
+        headers: dict = None,
+        params: dict = None,
+        data: dict = None,
+        json: dict = None,
+    ):
+        request_headers = self.session.headers.copy()
+        request_headers.update({"Authorization": f"Bearer {self.access_jwt}"})
+        if headers:
+            request_headers.update(headers)
+        response = self.session.request(
+            method=method,
+            url=url,
+            params=params,
+            data=data,
+            json=json,
+            headers=request_headers,
+        )
+        return response
+
+    def create_record(self, collection: str, record: dict, repo: str):
+        url = f"{BSKY_BASE_URL}{CREATE_RECORD_URL}"
+        data = dict(collection=collection, record=record, repo=repo)
+        return self.send_request(method="POST", url=url, json=data)
+
+    def delete_record(self, collection: str, repo: str, rkey: str):
+        url = f"{BSKY_BASE_URL}{DELETE_RECORD_URL}"
+        data = dict(collection=collection, repo=repo, rkey=rkey)
+        return self.send_request(method="POST", url=url, json=data)
+
+    def get_createdAt_now(self):
+        now = datetime.now(timezone.utc)
+        createdAt = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+        return createdAt
diff --git a/pybsky/core/__init__.py b/pybsky/core/__init__.py
new file mode 100644
index 0000000..a41b64b
--- /dev/null
+++ b/pybsky/core/__init__.py
@@ -0,0 +1,4 @@
+from .user_agents import *
+from .configs import *
+from .exceptions import *
+from .utils import *
diff --git a/pybsky/core/configs.py b/pybsky/core/configs.py
new file mode 100644
index 0000000..2e5f5bd
--- /dev/null
+++ b/pybsky/core/configs.py
@@ -0,0 +1,17 @@
+BSKY_BASE_URL = "https://bsky.social/xrpc/"
+FEED_URL = "app.bsky.feed.getAuthorFeed"
+SERVER_URL = "bsky.social"
+GET_Followers_URL = "app.bsky.graph.getFollowers"
+GET_FOLLOWS_URL = "app.bsky.graph.getFollows"
+CREATE_RECORD_URL = "com.atproto.repo.createRecord"
+GET_PROFILE_URL = "app.bsky.actor.getProfile"
+FollOW_URL = "app.bsky.graph.follow"
+DELETE_RECORD_URL = "com.atproto.repo.deleteRecord"
+GET_ACCOUNT_INVITATION_URL = "com.atproto.server.getAccountInviteCodes"
+GET_TIMELINE_URL = "app.bsky.feed.getTimeline"
+GET_POST_THREAD = "app.bsky.feed.getPostThread"
+GET_POSTS_URL = "app.bsky.feed.getPosts"
+POST_URL = "app.bsky.feed.post"
+UPLOAD_BLOB_URL = "com.atproto.repo.uploadBlob"
+IMAGES_URL = "app.bsky.embed.images"
+LIKE_URL = "app.bsky.feed.like"
diff --git a/pybsky/core/exceptions.py b/pybsky/core/exceptions.py
new file mode 100644
index 0000000..320576c
--- /dev/null
+++ b/pybsky/core/exceptions.py
@@ -0,0 +1,18 @@
+class AuthenticationRequiredException(Exception):
+    """Invalid identifier(username.bsky.social) or password"""
+
+
+class UnknownResponseException(Exception):
+    """Unknown Response"""
+
+
+class BadRequestResponseException(Exception):
+    """Bad Response"""
+
+
+class ProfileRequiredException(Exception):
+    """Profile required Exception"""
+
+
+class NotFollowingException(Exception):
+    """Not Following Exception"""
diff --git a/pybsky/core/user_agents.py b/pybsky/core/user_agents.py
new file mode 100644
index 0000000..40b06be
--- /dev/null
+++ b/pybsky/core/user_agents.py
@@ -0,0 +1,95 @@
+USER_AGENTS = [
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8_1 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) \
+            Version/14.1.2 Mobile/15E148 Safari/604.1",
+    "Mozilla/5.0 (Linux; U; Android 2.2) \
+        AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+    "Mozilla/5.0 (Windows NT 5.1) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.71 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36",
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) \
+            Version/13.1.1 Mobile/15E148 Safari/604.1",
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15",
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) \
+            Version/14.0.3 Mobile/15E148 Safari/604.1",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 6.1; WOW64) \
+        AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.83 Safari/537.1",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36",
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko)",
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) \
+            Version/14.0.3 Mobile/15E148 Safari/604.1",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) \
+            Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) \
+            Chrome/70.0.3538.102 Safari/537.36 Edge/18.18363",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) \
+            Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36",
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36",
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_2 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) \
+            Version/14.0.1 Mobile/15E148 Safari/604.1",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:94.0) Gecko/20100101 Firefox/94.0",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 5.1; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36",
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15",
+    "Mozilla/5.0 (Windows NT 6.2; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36",
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) \
+            Version/14.1.2 Mobile/15E148 Safari/604.1",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36",
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko)",
+    "Mozilla/5.0 (iPad; CPU OS 9_3_5 like Mac OS X) \
+        AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13G36",
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Safari/605.1.15",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+        AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36",
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15",
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) \
+        AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
+]
diff --git a/pybsky/core/utils.py b/pybsky/core/utils.py
new file mode 100644
index 0000000..f8bb0f9
--- /dev/null
+++ b/pybsky/core/utils.py
@@ -0,0 +1,42 @@
+import json
+import requests
+from .exceptions import (
+    AuthenticationRequiredException,
+    UnknownResponseException,
+    BadRequestResponseException,
+)
+
+Response = requests.models.Response
+
+
+def validate_value(value, cls):
+    if not isinstance(value, cls):
+        raise TypeError(f"{value} must be in type {cls}")
+    return True
+
+
+def json_decode(s: str) -> list | dict:
+    return json.loads(s)
+
+
+def json_encode(obj: object) -> str:
+    return json.dumps(obj, indent=4, ensure_ascii=False)
+
+
+def validate_get_response(response: Response):
+    if response.text and response.text != "":
+        content = response.json()
+    else:
+        content = response.text
+    if response.status_code == 200:
+        return content
+    elif response.status_code == 401:
+        raise AuthenticationRequiredException(content["message"])
+    elif response.status_code == 400:
+        raise BadRequestResponseException(content["message"])
+    else:
+        raise UnknownResponseException(
+            f"status code => {response.status_code} , response => {response.text}"
+        )
+
+    # TODO => handle all type of response
diff --git a/pybsky/mixins/__init__.py b/pybsky/mixins/__init__.py
new file mode 100644
index 0000000..5f1c112
--- /dev/null
+++ b/pybsky/mixins/__init__.py
@@ -0,0 +1,7 @@
+from .feed import FeedMixin
+from .login import LoginMixin
+from .graph import GraphMixin
+from .profile import ProfileMixin
+from .server import ServerMixin
+from .post import PostMixin
+from .repo import RepoMixin
diff --git a/mixins/base.py b/pybsky/mixins/base.py
similarity index 100%
rename from mixins/base.py
rename to pybsky/mixins/base.py
diff --git a/pybsky/mixins/feed.py b/pybsky/mixins/feed.py
new file mode 100644
index 0000000..356c4d7
--- /dev/null
+++ b/pybsky/mixins/feed.py
@@ -0,0 +1,64 @@
+from ..core import (
+    validate_get_response,
+    validate_value,
+    FEED_URL,
+    BSKY_BASE_URL,
+    GET_TIMELINE_URL,
+    GET_POSTS_URL,
+    GET_POST_THREAD,
+)
+
+
+class FeedMixin:
+    def get_author_feed(self, actor: str, limit: int = 30):
+        validate_value(actor, str)
+        headers = {
+            "origin": "https://bsky.app",
+            "referer": "https://bsky.app/",
+            "sec-fetch-dest": "empty",
+            "sec-fetch-mode": "cors",
+            "sec-fetch-site": "cross-site",
+            "user-agent": self.user_agent,
+            "authorization": f"Bearer {self.access_jwt}",
+        }
+
+        params = {
+            "actor": self.get_actor(actor),
+            "limit": limit,
+        }
+        url = f"{BSKY_BASE_URL}{FEED_URL}"
+        response = self.session.get(url, params=params, headers=headers)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def get_timeline(self, algorithm: str = "reverse-chronological", limit: int = 30):
+        validate_value(algorithm, str)
+        validate_value(limit, int)
+        params = {
+            "actor": algorithm,
+            "limit": limit,
+        }
+        url = f"{BSKY_BASE_URL}{GET_TIMELINE_URL}"
+        response = self.send_request(method="GET", url=url, params=params)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def get_posts(self, uris: str):
+        validate_value(uris, str)
+        params = {
+            "uris": uris,
+        }
+        url = f"{BSKY_BASE_URL}{GET_POSTS_URL}"
+        response = self.send_request(method="GET", url=url, params=params)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def get_post_thread(self, uri: str):
+        validate_value(uri, str)
+        params = {
+            "uri": uri,
+        }
+        url = f"{BSKY_BASE_URL}{GET_POST_THREAD}"
+        response = self.send_request(method="GET", url=url, params=params)
+        validated_response = validate_get_response(response)
+        return validated_response
diff --git a/pybsky/mixins/graph.py b/pybsky/mixins/graph.py
new file mode 100644
index 0000000..a2e704b
--- /dev/null
+++ b/pybsky/mixins/graph.py
@@ -0,0 +1,80 @@
+from ..core import (
+    validate_get_response,
+    validate_value,
+    GET_Followers_URL,
+    GET_FOLLOWS_URL,
+    BSKY_BASE_URL,
+    ProfileRequiredException,
+    FollOW_URL,
+    NotFollowingException,
+)
+from datetime import datetime, timezone
+
+
+class GraphMixin:
+    def get_followers(self, actor: str, limit: int = 30, cursor: str = None):
+        validate_value(actor, str)
+        validate_value(limit, int)
+        if cursor:
+            validate_value(cursor, str)
+        params = {
+            "actor": self.get_actor(actor),
+            "limit": limit,
+        }
+        if cursor:
+            params["cursor"] = cursor
+        url = f"{BSKY_BASE_URL}{GET_Followers_URL}"
+        response = self.send_request(method="GET", url=url, params=params)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def get_followings(self, actor: str, limit: int = 30, cursor: str = None):
+        validate_value(actor, str)
+        validate_value(limit, int)
+        if cursor:
+            validate_value(cursor, str)
+        params = {
+            "actor": self.get_actor(actor),
+            "limit": limit,
+        }
+        if cursor:
+            params["cursor"] = cursor
+        url = f"{BSKY_BASE_URL}{GET_FOLLOWS_URL}"
+        response = self.send_request(method="GET", url=url, params=params)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def follow(self, actor: str):
+        exact_actor = self.get_actor(actor)
+        if not self.did:
+            raise ProfileRequiredException()
+        if "did" not in exact_actor:
+            actor_profile = self.get_profile(exact_actor)
+            subject = actor_profile["did"]
+        else:
+            subject = exact_actor
+        now = datetime.now(timezone.utc)
+        createdAt = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+        record = {"subject": subject, "createdAt": createdAt, "$type": FollOW_URL}
+
+        response = self.create_record(
+            collection=FollOW_URL, repo=self.did, record=record
+        )
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def unfollow_by_actor(self, actor: str):
+        exact_actor = self.get_actor(actor)
+        if not self.did:
+            raise ProfileRequiredException()
+        profile = self.get_profile(exact_actor)
+        if "viewer" not in profile or "following" not in profile["viewer"]:
+            raise NotFollowingException()
+        following_record = profile["viewer"]["following"]
+        rkey = following_record.split("/")[-1]
+        return self.unfollow_by_rkey(rkey)
+
+    def unfollow_by_rkey(self, rkey: str):
+        response = self.delete_record(collection=FollOW_URL, repo=self.did, rkey=rkey)
+        validated_response = validate_get_response(response)
+        return validated_response
diff --git a/pybsky/mixins/login.py b/pybsky/mixins/login.py
new file mode 100644
index 0000000..4deefd7
--- /dev/null
+++ b/pybsky/mixins/login.py
@@ -0,0 +1,44 @@
+import random
+import requests
+
+from ..core import USER_AGENTS, validate_value, validate_get_response
+
+
+class LoginMixin:
+    def login(self, username: str, password: str):
+        validate_value(username, str)
+        validate_value(password, str)
+        if len(username) == 0:
+            raise ValueError("username should not be empty")
+        if len(password) == 0:
+            raise ValueError("username should not be empty")
+
+        session = requests.Session()
+        session.timeout = 30
+        session.proxies = self.session.proxies
+
+        self.user_agent = random.choice(USER_AGENTS)
+
+        headers = {
+            "origin": "https://bsky.app",
+            "referer": "https://bsky.app/",
+            "sec-fetch-dest": "empty",
+            "sec-fetch-mode": "cors",
+            "sec-fetch-site": "cross-site",
+            "user-agent": self.user_agent,
+        }
+
+        payload = {
+            "identifier": self.get_actor(username),
+            "password": password,
+        }
+        url = "https://bsky.social/xrpc/com.atproto.server.createSession"
+        response = requests.post(url, headers=headers, json=payload)
+
+        validated_response = validate_get_response(response)
+        self.access_jwt = validated_response["accessJwt"]
+        self.refresh_jwt = validated_response["refreshJwt"]
+        self.session.headers.update({"authorization": f"Bearer {self.access_jwt}"})
+        own_profile = self.get_profile(self.get_actor(username))
+        self.own_profile = own_profile
+        return validated_response
diff --git a/pybsky/mixins/post.py b/pybsky/mixins/post.py
new file mode 100644
index 0000000..9a56d66
--- /dev/null
+++ b/pybsky/mixins/post.py
@@ -0,0 +1,71 @@
+from ..core import (
+    validate_get_response,
+    IMAGES_URL,
+    POST_URL,
+)
+
+
+class PostMixin:
+    def post_text(
+        self,
+        text: str,
+        langs: list = None,
+    ):
+        if not langs or len(langs) == 0:
+            langs = ["fa", "en"]
+        createdAt = self.get_createdAt_now()
+        record = {
+            "text": text,
+            "langs": langs,
+            "createdAt": createdAt,
+            "$type": POST_URL,
+        }
+        response = self.create_record(collection=POST_URL, repo=self.did, record=record)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def reply_text(
+        self,
+        text: str,
+        root_cid: str,
+        root_uri: str,
+        parent_cid: str,
+        parent_uri: str,
+        langs: list = None,
+    ):
+        if not langs or len(langs) == 0:
+            langs = ["fa", "en"]
+        createdAt = self.get_createdAt_now()
+        record = {
+            "text": text,
+            "langs": langs,
+            "createdAt": createdAt,
+            "$type": POST_URL,
+            "reply": {
+                "root": {"cid": root_cid, "uri": root_uri},
+                "parent": {"cid": parent_cid, "uri": parent_uri},
+            },
+        }
+
+        response = self.create_record(collection=POST_URL, repo=self.did, record=record)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def post_text_images(self, text: str, images: list, langs: list = None):
+        if not langs or len(langs) == 0:
+            langs = ["fa", "en"]
+        createdAt = self.get_createdAt_now()
+        record = {
+            "text": text,
+            "langs": langs,
+            "createdAt": createdAt,
+            "$type": POST_URL,
+            "embed": {
+                "$type": IMAGES_URL,
+                "images": [{"image": x, "alt": ""} for x in images],
+            },
+        }
+
+        response = self.create_record(collection=POST_URL, repo=self.did, record=record)
+        validated_response = validate_get_response(response)
+        return validated_response
diff --git a/pybsky/mixins/profile.py b/pybsky/mixins/profile.py
new file mode 100644
index 0000000..b8bff67
--- /dev/null
+++ b/pybsky/mixins/profile.py
@@ -0,0 +1,13 @@
+from ..core import validate_get_response, validate_value, GET_PROFILE_URL, BSKY_BASE_URL
+
+
+class ProfileMixin:
+    def get_profile(self, actor: str):
+        validate_value(actor, str)
+        params = {
+            "actor": self.get_actor(actor),
+        }
+        url = f"{BSKY_BASE_URL}{GET_PROFILE_URL}"
+        response = self.send_request(method="GET", url=url, params=params)
+        validated_response = validate_get_response(response)
+        return validated_response
diff --git a/pybsky/mixins/repo.py b/pybsky/mixins/repo.py
new file mode 100644
index 0000000..ca060ae
--- /dev/null
+++ b/pybsky/mixins/repo.py
@@ -0,0 +1,28 @@
+from ..core import (
+    validate_get_response,
+    BSKY_BASE_URL,
+    UPLOAD_BLOB_URL,
+    LIKE_URL,
+)
+
+
+class RepoMixin:
+    def upload_blob(self, file: bytes):
+        url = f"{BSKY_BASE_URL}{UPLOAD_BLOB_URL}"
+        data = file
+        headers = {"content-type": "image/jpeg"}
+        response = self.send_request(method="POST", url=url, data=data, headers=headers)
+        validated_response = validate_get_response(response)
+        return validated_response
+
+    def like(self, uri: str, cid: str):
+        createdAt = self.get_createdAt_now()
+        record = {
+            "createdAt": createdAt,
+            "$type": LIKE_URL,
+            "subject": {"uri": uri, "cid": cid},
+        }
+
+        response = self.create_record(collection=LIKE_URL, repo=self.did, record=record)
+        validated_response = validate_get_response(response)
+        return validated_response
diff --git a/pybsky/mixins/server.py b/pybsky/mixins/server.py
new file mode 100644
index 0000000..1770907
--- /dev/null
+++ b/pybsky/mixins/server.py
@@ -0,0 +1,9 @@
+from ..core import validate_get_response, GET_ACCOUNT_INVITATION_URL, BSKY_BASE_URL
+
+
+class ServerMixin:
+    def get_account_invitation(self):
+        url = f"{BSKY_BASE_URL}{GET_ACCOUNT_INVITATION_URL}"
+        response = self.send_request(method="GET", url=url)
+        validated_response = validate_get_response(response)
+        return validated_response
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..be3e416
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,46 @@
+[project]
+name = "pybsky"
+version = "0.0.15"
+authors = [
+  { name="Mohammadreza softrebel", email="sh.mohammad66@yahoo.com" },
+    { name="Mahdi Ovan", email="mahdi.ovan@yahoo.com" },
+]
+description ='Python Client for bsky social media'
+readme = "README.md"
+requires-python = ">=3.10"
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+]
+dependencies = [
+    "requests==2.31.0",
+]
+
+[project.urls]
+"Homepage" = "https://github.com/softrebel/pybsky"
+"Bug Tracker" = "https://github.com/softrebel/pybsky/issues"
+
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[tool.ruff]
+# Enable flake8-bugbear (`B`) rules.
+select = ["E", "F", "B"]
+
+# Never enforce `E501` (line length violations).
+ignore = []
+
+# Avoid trying to fix flake8-bugbear (`B`) violations.
+unfixable = ["B"]
+
+# Ignore `E402` (import violations) in all `__init__.py` files`.
+[tool.ruff.per-file-ignores]
+"__init__.py" = ["E402","F403","F401"]
+
+[tool.setuptools.packages.find]
+where = ["."]  # list of folders that contain the packages (["."] by default)
+include = ["pybsky"]  # package names should match these glob patterns (["*"] by default)
+exclude = []  # exclude packages matching these glob patterns (empty by default)
+namespaces = false
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9fa1904
Binary files /dev/null and b/requirements.txt differ


Test patch:
diff --git a/tests/base.py b/tests/base.py
new file mode 100644
index 0000000..daa249f
--- /dev/null
+++ b/tests/base.py
@@ -0,0 +1,16 @@
+import unittest
+from pybsky import Client
+
+
+class TestBase(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        proxies = {
+            "http": "socks5h://127.0.0.1:27020",
+            "https": "socks5h://127.0.0.1:27020",
+        }
+        cls.client = Client(proxies=proxies)
+
+    @classmethod
+    def tearDownClass(cls):
+        del cls.client
diff --git a/tests/feed_test.py b/tests/feed_test.py
new file mode 100644
index 0000000..32e7342
--- /dev/null
+++ b/tests/feed_test.py
@@ -0,0 +1,28 @@
+from tests.base import TestBase
+
+from pybsky import BSKY_BASE_URL, FEED_URL, BadRequestResponseException
+import requests_mock
+
+
+class FeedTest(TestBase):
+    def test_login_success(self):
+        with requests_mock.mock() as m:
+            m.get(f"{BSKY_BASE_URL}{FEED_URL}", text='{"feed":[]}')
+            result = self.client.get_author_feed("iooobp2.bsky.social")
+            self.assertEqual({"feed": []}, result)
+
+    def test_login_failed(self):
+        with requests_mock.mock() as m:
+            m.get(
+                f"{BSKY_BASE_URL}{FEED_URL}",
+                text="""{
+                            "error": "InvalidToken",
+                            "message": "Token could not be verified"
+                        }""",
+                status_code=400,
+            )
+            self.assertRaises(
+                BadRequestResponseException,
+                self.client.get_author_feed,
+                "iooobp2.bsky.social",
+            )
diff --git a/tests/login_test.py b/tests/login_test.py
new file mode 100644
index 0000000..79ead8b
--- /dev/null
+++ b/tests/login_test.py
@@ -0,0 +1,23 @@
+from tests.base import TestBase
+
+from pybsky import AuthenticationRequiredException
+
+
+class LoginTest(TestBase):
+    def test_login_failed(self):
+        username = "onlycode"
+        password = "1234"
+        self.assertRaises(
+            AuthenticationRequiredException,
+            self.client.login,
+            username=username,
+            password=password,
+        )
+
+    def test_username_type_error(self):
+        self.assertRaises(TypeError, self.client.login, username=123, password="")
+        self.assertRaises(ValueError, self.client.login, username="", password="")
+
+    def test_password_type_error(self):
+        self.assertRaises(TypeError, self.client.login, username="test", password=123)
+        self.assertRaises(ValueError, self.client.login, username="test", password="")
diff --git a/tests/utils_test.py b/tests/utils_test.py
new file mode 100644
index 0000000..326dd98
--- /dev/null
+++ b/tests/utils_test.py
@@ -0,0 +1,46 @@
+import unittest
+
+from pybsky import (
+    validate_value,
+    validate_get_response,
+    BadRequestResponseException,
+    AuthenticationRequiredException,
+)
+
+
+class MockResponse:
+    def __init__(self, json_data, status_code):
+        self.json_data = json_data
+        self.status_code = status_code
+
+    def json(self):
+        return self.json_data
+
+
+class UtilsTest(unittest.TestCase):
+    def test_validate_value_success(self):
+        self.assertTrue(validate_value("salam", str))
+
+    def test_validate_value_failed(self):
+        self.assertRaises(TypeError, validate_value, "salam", int)
+
+    def test_response_200(self):
+        mock_response = MockResponse({"feed": []}, 200)
+        response = validate_get_response(mock_response)
+        self.assertEqual(response, {"feed": []})
+
+    def test_response_400(self):
+        mock_response = MockResponse(
+            {"error": "InvalidToken", "message": "Token could not be verified"}, 400
+        )
+        self.assertRaises(
+            BadRequestResponseException, validate_get_response, mock_response
+        )
+
+    def test_response_401(self):
+        mock_response = MockResponse(
+            {"error": "AuthMissing", "message": "Authentication Required"}, 401
+        )
+        self.assertRaises(
+            AuthenticationRequiredException, validate_get_response, mock_response
+        )