From 301b3d2bbeda93eac6cab4f4b907d65ff3e2401e Mon Sep 17 00:00:00 2001 From: vsakkas Date: Mon, 6 Nov 2023 22:41:38 +0200 Subject: [PATCH] Add Bard client (#5) - Add Bard client that supports starting a conversation, sending prompts, resetting and closing the conversation - Add first test - Add tests pipeline --- .github/workflows/test.yml | 43 +++++++++++ README.md | 113 ++++++++++++++++++++++++++- bard/bard.py | 151 ++++++++++++++++++++++++++++++++++++- bard/constants.py | 21 ++++++ bard/exceptions.py | 6 ++ bard/utils.py | 5 ++ pyproject.toml | 2 +- tests/test_ask.py | 9 +++ 8 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 bard/constants.py create mode 100644 bard/exceptions.py create mode 100644 bard/utils.py create mode 100644 tests/test_ask.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..071006c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: Test + +on: + push: + branches: + - master + pull_request: + branches: + - master + +env: + SECURE_1PSID: ${{ secrets.SECURE_1PSID }} + SECURE_1PSIDTS: ${{ secrets.SECURE_1PSIDTS }} + +jobs: + build-and-test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + + - name: Install dependencies + run: poetry install + + - name: Run type checker + run: poetry run mypy --ignore-missing-imports bard/ + + - name: Run tests + run: poetry run pytest --cov diff --git a/README.md b/README.md index 5c6f9f1..54de495 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ -# Bard.py +# Bard.py -[![Latest Release](https://img.shields.io/github/v/release/vsakkas/bard.py.svg)](https://github.com/vsakkas/bard.py/releases/tag/v0.1.0) +[![Latest Release](https://img.shields.io/github/v/release/vsakkas/bard.py.svg)](https://github.com/vsakkas/bard.py/releases/tag/v0.2.0) [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/vsakkas/bard.py/blob/master/LICENSE) -Python client for Bard. +Python Client for Bard, a Chat Based AI tool by Google. > **Note** > This is an **unofficial** client. +## Features + +- Connect to Bard, Google's AI-powered personal assistant. +- Ask questions and have a conversation. +- Use asyncio for efficient and non-blocking I/O operations. + ## Requirements - Python 3.10 or newer @@ -28,6 +34,107 @@ or, if you use [poetry](https://python-poetry.org/): poetry add bard-py ``` +> **Note** +> Make sure you're using the latest version of Bard.py to ensure best compatibility with Bard. + +## Usage + +### Prerequisites + +To use Bard.py you first need to extract the `__Secure-1PSID` and `__Secure-1PSIDTS` cookies from the Bard web page. These cookies are used to authenticate your requests to the Bard API. + +To get the cookies, follow these steps on Chrome: +- Go to the [Bard web page](https://bard.google.com/). +- Write a message on the chat dialog that appears. +- Open the developer tools in your browser (usually by pressing `F12` or right-clicking on the chat dialog and selecting `Inspect`). +- Select the `Application` tab and click on the `Cookies` option to view all cookies associated with `https://bard.google.com`. +- Look for the `__Secure-1PSID` and `__Secure-1PSIDTS` cookies and click on them to expand their details. +- Copy the values of the cookies (they should look like a long string of letters and numbers). + +Then, set them as environment variables in your shell: + +```bash +export SECURE_1PSID= +export SECURE_1PSIDTS= +``` + +or, in your Python code: + +```python +os.environ["SECURE_1PSID"] = "" +os.environ["SECURE_1PSIDTS"] = "" +``` + +### Example + +You can use Bard.py to easily create a CLI client for Bard: + +```python +import asyncio + +from bard import BardClient + + +async def main() -> None: + async with BardClient() as bard: + while True: + prompt = input("You: ") + + if prompt == "!reset": + await bard.reset_conversation() + continue + elif prompt == "!exit": + break + + response = await bard.ask(prompt) + print(f"Bard: {response}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Bard Client + +You can create a Bard Client and initialize a connection with Bard which starts a conversation: + +```python +bard = BardClient() + +await bard.start_conversation() + +# Conversation + +await bard.end_conversation() +``` + +Alternatively, you can use the `async with` statement to keep the code compact: + +```python +async with BardClient() as bard: + # Conversation +``` + +### Reset Conversation + +You can reset the conversation in order to make the client forget the previous conversation: + +```python +async with BardClient() as bard: + # Conversation + await bard.reset_conversation() +``` + +### Ask + +You can ask Bard questions and get the results: + +```python +async with BardClient() as bard: + response = await bard.ask("When was Bard released?") + print(response) +``` + ## License This project is licensed under the MIT License - see the [LICENSE](https://github.com/vsakkas/bard.py/blob/master/LICENSE) file for details. diff --git a/bard/bard.py b/bard/bard.py index 6b307dc..a4510f4 100644 --- a/bard/bard.py +++ b/bard/bard.py @@ -1,3 +1,150 @@ +import json +import random +import re +from os import environ + +from aiohttp import ClientSession + +from bard.constants import BARD_STREAM_GENERATE_URL, BARD_URL, HEADERS +from bard.exceptions import AskException, CreateConversationException +from bard.utils import double_json_stringify + + class BardClient: - def __init__(self) -> None: - pass + def __init__( + self, secure_1psid: str | None = None, secure_1psidts: str | None = None + ) -> None: + """ + Client for Bard. + """ + self.secure_1psid = secure_1psid if secure_1psid else environ["SECURE_1PSID"] + self.secure_1psidts = ( + secure_1psidts if secure_1psidts else environ["SECURE_1PSIDTS"] + ) + self.conversation_id: str | None = None + self.response_id: str | None = None + self.choice_id: str | None = None + self.session: ClientSession | None = None + + async def __aenter__(self) -> "BardClient": + await self.start_conversation() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + await self.close_conversation() + + async def _get_session(self, force_close: bool = False) -> ClientSession: + # Use cookies to create a conversation. + cookies = { + "__Secure-1PSID": self.secure_1psid, + "__Secure-1PSIDTS": self.secure_1psidts, + } + + if self.session and force_close: + await self.session.close() + + if not self.session: + self.session = ClientSession( + headers=HEADERS, + cookies=cookies, + ) + + return self.session + + def _build_ask_parameters(self) -> dict: + return { + "bl": "boq_assistant-bard-web-server_20231031.09_p7", + "_reqid": "".join(str(random.randint(0, 9)) for _ in range(7)), + "rt": "c", + } + + def _build_ask_arguments(self, prompt: str) -> dict: + request_data = [ + [prompt], + None, + [self.conversation_id, self.response_id, self.choice_id], + ] + + return { + "f.req": double_json_stringify(request_data), + "at": self.snlm0e, + } + + async def start_conversation(self) -> None: + """ + Connect to Bard and create a new conversation. + """ + session = await self._get_session(force_close=True) + + async with session.get(BARD_URL) as response: + if response.status != 200: + raise CreateConversationException( + f"Failed to create conversation, received status: {response.status}" + ) + + response_text = await response.text() + + snlm0e_dict = re.search(r"\"SNlM0e\":\"(?P.*?)\"", response_text) + + if not snlm0e_dict: + raise CreateConversationException( + "Failed to create conversation, SNlM0e value was not found." + ) + + self.snlm0e = snlm0e_dict.group("value") + + async def ask(self, prompt: str) -> str: + """ + Send a prompt to Bard and return the answer. + + Parameters + ---------- + prompt: str + The prompt that needs to be sent to Bard. + + Returns + ------- + str + The response from Bard. + """ + parameters = self._build_ask_parameters() + arguments = self._build_ask_arguments(prompt) + + session = await self._get_session() + + async with session.post( + BARD_STREAM_GENERATE_URL, params=parameters, data=arguments + ) as response: + if response.status != 200: + raise AskException( + f"Failed to get response, received status: {response.status}" + ) + + response_text = await response.text() + response_data = json.loads(response_text.splitlines()[3]) + + message = json.loads(response_data[0][2]) + + self.conversation_id = message[1][0] + self.response_id = message[1][1] + self.choice_id = message[4][0][0] + + return message[4][0][1][0] + + async def reset_conversation(self) -> None: + """ + Clear current conversation information and connection and start new ones. + """ + await self.close_conversation() + await self.start_conversation() + + async def close_conversation(self) -> None: + """ + Close all connections to Bard. Clear conversation information. + """ + if self.session and not self.session.closed: + await self.session.close() + + self.conversation_id = None + self.response_id = None + self.choice_id = None diff --git a/bard/constants.py b/bard/constants.py new file mode 100644 index 0000000..121b14b --- /dev/null +++ b/bard/constants.py @@ -0,0 +1,21 @@ +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" + +HEADERS = { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "Origin": "https://bard.google.com", + "Referer": "https://bard.google.com/", + "Sec-Ch-Ua": '"Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": "Windows", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": USER_AGENT, + "X-Same-Domain": "1", +} + +BARD_URL = "https://bard.google.com/chat" +BARD_STREAM_GENERATE_URL = "https://bard.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate" diff --git a/bard/exceptions.py b/bard/exceptions.py new file mode 100644 index 0000000..6711629 --- /dev/null +++ b/bard/exceptions.py @@ -0,0 +1,6 @@ +class CreateConversationException(Exception): + pass + + +class AskException(Exception): + pass diff --git a/bard/utils.py b/bard/utils.py new file mode 100644 index 0000000..e9a9835 --- /dev/null +++ b/bard/utils.py @@ -0,0 +1,5 @@ +import json + + +def double_json_stringify(data) -> str: + return json.dumps([None, json.dumps(data)]) diff --git a/pyproject.toml b/pyproject.toml index f61c517..cb3a429 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bard-py" -version = "0.1.0" +version = "0.2.0" description = "Python Client for Bard." authors = ["vsakkas "] license = "MIT" diff --git a/tests/test_ask.py b/tests/test_ask.py new file mode 100644 index 0000000..9f7e090 --- /dev/null +++ b/tests/test_ask.py @@ -0,0 +1,9 @@ +import pytest + +from bard import BardClient + + +@pytest.mark.asyncio +async def test_ask() -> None: + async with BardClient() as bard: + _ = await bard.ask("Tell me a joke")