├── .env.example ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── agentcomms ├── __init__.py ├── adminpanel │ ├── __init__.py │ ├── constants.py │ ├── files.py │ ├── page.py │ ├── server.py │ └── tests │ │ ├── __init__.py │ │ ├── files.py │ │ └── server.py ├── discord │ ├── __init__.py │ ├── actions.py │ ├── connector.py │ └── tests │ │ ├── __init__.py │ │ └── tests.py └── twitter │ ├── __init__.py │ ├── actions.py │ ├── connector.py │ └── tests │ ├── __init__.py │ └── tests.py ├── requirements.txt ├── resources ├── image.jpg └── youcreatethefuture.jpg ├── setup.py └── test.py /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | 3 | TWITTER_EMAIL= 4 | TWITTER_USERNAME= 5 | TWITTER_PASSWORD= 6 | 7 | DISCORD_API_TOKEN= 8 | 9 | ELEVENLABS_API_KEY= 10 | ELEVENLABS_VOICE="Rachel" 11 | ELEVENLABS_MODEL="eleven_monolingual_v1" -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: ${{ secrets.pypi_username }} 39 | password: ${{ secrets.pypi_password }} 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | 3 | on: [push] 4 | 5 | env: 6 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 7 | TWITTER_EMAIL: ${{ secrets.TWITTER_EMAIL }} 8 | TWITTER_USERNAME: ${{ secrets.TWITTER_USERNAME }} 9 | TWITTER_PASSWORD: ${{ secrets.TWITTER_PASSWORD }} 10 | DISCORD_API_TOKEN: ${{ secrets.DISCORD_API_TOKEN }} 11 | ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} 12 | ELEVENLABS_VOICE: ${{ secrets.ELEVENLABS_VOICE }} 13 | ELEVENLABS_MODEL: ${{ secrets.ELEVENLABS_MODEL }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | python-version: ["3.10"] 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install pytest 31 | pip install -r requirements.txt 32 | - name: Running tests 33 | run: | 34 | pytest test.py 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .DS_Store 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | .vscode/ 164 | .chroma 165 | memory 166 | test 167 | files/ 168 | .env 169 | twitter.cookies 170 | temp/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 M̵̞̗̝̼̅̏̎͝Ȯ̴̝̻̊̃̋̀Õ̷̼͋N̸̩̿͜ ̶̜̠̹̼̩͒ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # agentcomms 2 | 3 | Connectors for your agent to the outside world. 4 | 5 | - Discord connector with (voice and chat, DMs coming) 6 | - Twitter connector (feed only, DMs coming) 7 | - Admin Panel - simple web interface to chat with your agent and upload files 8 | 9 | 10 | 11 | [![Lint and Test](https://github.com/AutonomousResearchGroup/agentcomms/actions/workflows/test.yml/badge.svg)](https://github.com/AutonomousResearchGroup/agentcomms/actions/workflows/test.yml) 12 | [![PyPI version](https://badge.fury.io/py/agentcomms.svg)](https://badge.fury.io/py/agentcomms) 13 | 14 | # Installation 15 | 16 | ```bash 17 | pip install agentcomms 18 | ``` 19 | 20 | # Twitter Usage Guide 21 | 22 | This module uses a set of environment variables to interact with Twitter, so you'll need to set the following before using: 23 | 24 | - `TWITTER_EMAIL`: The email address for your Twitter account. 25 | - `TWITTER_USERNAME`: The username for your Twitter account. 26 | - `TWITTER_PASSWORD`: The password for your Twitter account. 27 | 28 | ## Setting up the Twitter connector 29 | 30 | Before you can start using the Twitter connector, you have to initialize it. The initialization is done using the `start_twitter_connector` function. 31 | 32 | ```python 33 | import twitter 34 | twitter.start_twitter_connector() 35 | ``` 36 | 37 | This will start the twitter connector with default parameters. If you wish to customize the email, username, password, or session storage path, you can use the `start_twitter` function like so: 38 | 39 | ```python 40 | twitter.start_twitter(email="my_email@example.com", username="my_username", password="my_password", session_storage_path="my_session.cookies") 41 | ``` 42 | 43 | ### Liking a tweet 44 | 45 | To like a tweet, you can use the `like_tweet` function. Pass in the id of the tweet you wish to like. 46 | 47 | ```python 48 | twitter.like_tweet("1234567890") 49 | ``` 50 | 51 | ### Replying to a tweet 52 | 53 | To reply to a tweet, you can use the `reply_to_tweet` function. Pass in the message you wish to send, and the id of the tweet you're replying to. 54 | 55 | ```python 56 | twitter.reply_to_tweet("This is a great tweet!", "1234567890") 57 | ``` 58 | 59 | ### Posting a tweet 60 | 61 | To post a new tweet, you can use the `tweet` function. Pass in the message you wish to tweet. You can optionally pass in a media object to attach to the tweet. 62 | 63 | ```python 64 | twitter.tweet("Hello, Twitter!") 65 | ``` 66 | 67 | ## Registering feed handlers 68 | 69 | Feed handlers are functions that get called whenever there are new tweets in the feed. They can be registered using the `register_feed_handler` function. 70 | 71 | ```python 72 | def my_feed_handler(tweet): 73 | print(f"New tweet from {tweet['user']['name']}: {tweet['text']}") 74 | 75 | twitter.register_feed_handler(my_feed_handler) 76 | ``` 77 | 78 | You can also unregister a handler using the `unregister_feed_handler` function. 79 | 80 | ```python 81 | twitter.unregister_feed_handler(my_feed_handler) 82 | ``` 83 | 84 | ## Getting account information 85 | 86 | To get the current account object, you can use the `get_account` function. 87 | 88 | ```python 89 | account = twitter.get_account() 90 | print(account.username) 91 | ``` 92 | 93 | This will print out the username of the current Twitter account. 94 | 95 | # Discord Usage Guide 96 | 97 | The Discord connector works with both voice and text. For voice, you will need an Elevenlabs API key. 98 | 99 | ## Environment Variables 100 | 101 | Before you start, you need to set the environment variables for the bot to function correctly. Create a `.env` file in your project directory and set these variables: 102 | 103 | ```env 104 | DISCORD_API_TOKEN=your_discord_bot_token 105 | ELEVENLABS_API_KEY=your_elevenlabs_api_key 106 | ELEVENLABS_VOICE=voice_you_want_to_use 107 | ELEVENLABS_MODEL=model_you_want_to_use 108 | ``` 109 | 110 | - `DISCORD_API_TOKEN` is your Discord bot token, which you get when you create a new bot on the Discord developer portal. 111 | - `ELEVENLABS_API_KEY` is your Eleven Labs API key for their TTS service. 112 | - `ELEVENLABS_VOICE` is the voice you want to use for the TTS. You will have to check the Eleven Labs API documentation for the voices they support. 113 | - `ELEVENLABS_MODEL` is the TTS model you want to use. Again, you will have to check the Eleven Labs API documentation for the supported models. 114 | 115 | ## Running the Bot 116 | 117 | After setting your environment variables, you can run your bot by calling the `start_connector` function: 118 | 119 | ```python 120 | start_connector() 121 | ``` 122 | 123 | ## Registering Message Handlers 124 | 125 | Message handlers are functions that are executed when certain events happen in Discord, such as receiving a message. Here's how you can register a message handler: 126 | 127 | ### Create the Handler Function 128 | 129 | First, you need to create a function that will be executed when a message is received. This function should take one argument, which is the message that was received. The message object will contain all the information about the message, such as the content of the message, the author, and the channel where it was sent. 130 | 131 | Here's an example of a simple message handler function: 132 | 133 | ```python 134 | def handle_message(message): 135 | print(f"Received a message from {message.author}: {message.content}") 136 | ``` 137 | 138 | This function will simply print the author and content of every message that is received. 139 | 140 | ### Register the Handler Function 141 | 142 | To register the handler function, you use the `register_feed_handler` function and pass the handler function as an argument: 143 | 144 | ```python 145 | register_feed_handler(handle_message) 146 | ``` 147 | 148 | After calling this function, the `handle_message` function will be executed every time a message is received on Discord. 149 | 150 | ## Public Functions 151 | 152 | ### `send_message(message: str, channel_id: int)` 153 | 154 | This function is used to add a message to the queue. The message will be sent to the channel with the ID specified. 155 | 156 | ```python 157 | send_message("Hello world!", 1234567890) 158 | ``` 159 | 160 | ### `start_connector(discord_api_token: str)` 161 | 162 | This function is used to start the bot and the event loop, setting the bot to listen for events on Discord. 163 | 164 | ```python 165 | start_connector("your_discord_api_token") 166 | ``` 167 | 168 | ### `register_feed_handler(func: callable)` 169 | 170 | This function is used to register a new function as a feed handler. Feed handlers are functions that process or respond to incoming data in some way. 171 | 172 | ```python 173 | def my_func(data): 174 | print(data) 175 | 176 | register_feed_handler(my_func) 177 | ``` 178 | 179 | ### `unregister_feed_handler(func: callable)` 180 | 181 | This function is used to remove a function from the list of feed handlers. 182 | 183 | ```python 184 | unregister_feed_handler(my_func) 185 | ``` 186 | 187 | # Admin Panel Usage Guide 188 | 189 | ## Quickstart 190 | 191 | 1. **Start the server**: 192 | You can start the server with uvicorn like this: 193 | 194 | ```python 195 | import os 196 | 197 | if __name__ == "__main__": 198 | import uvicorn 199 | uvicorn.run("agentcomms:start_server", host="0.0.0.0", port=int(os.getenv("PORT", 8000))) 200 | ``` 201 | 202 | This will start the server at `http://localhost:8000`. 203 | 204 | 2. **Get a file**: 205 | Once the server is up and running, you can retrieve file content by sending a GET request to `/file/{path}` endpoint, where `{path}` is the path to the file relative to the server's current storage directory. 206 | 207 | ```python 208 | from agentcomms import get_file 209 | 210 | # Fetches the content of the file located at "./files/test.txt" 211 | file_content = get_file("test.txt") 212 | print(file_content) 213 | ``` 214 | 215 | 3. **Save a file**: 216 | Similarly, you can save content to a file by sending a POST request to `/file/` endpoint, with JSON data containing the `path` and `content` parameters. 217 | 218 | ```python 219 | from agentcomms import add_file 220 | 221 | # Creates a file named "test.txt" in the current storage directory 222 | # and writes "Hello, world!" to it. 223 | add_file("test.txt", "Hello, world!") 224 | ``` 225 | 226 | ## API Documentation 227 | 228 | AgentFS provides the following public functions: 229 | 230 | ### `start_server(storage_path=None)` 231 | 232 | Starts the FastAPI server. If a `storage_path` is provided, it sets the storage directory to the given path. 233 | 234 | **Arguments**: 235 | 236 | - `storage_path` (str, optional): The path to the storage directory. 237 | 238 | **Returns**: 239 | 240 | - None 241 | 242 | **Example**: 243 | 244 | ```python 245 | from agentcomms import start_server 246 | 247 | start_server("/my/storage/directory") 248 | ``` 249 | 250 | ### `get_server()` 251 | 252 | Returns the FastAPI application instance. 253 | 254 | **Arguments**: 255 | 256 | - None 257 | 258 | **Returns**: 259 | 260 | - FastAPI application instance. 261 | 262 | **Example**: 263 | 264 | ```python 265 | from agentcomms import get_server 266 | 267 | app = get_server() 268 | ``` 269 | 270 | ### `set_storage_path(new_path)` 271 | 272 | Sets the storage directory to the provided path. 273 | 274 | **Arguments**: 275 | 276 | - `new_path` (str): The path to the new storage directory. 277 | 278 | **Returns**: 279 | 280 | - `True` if the path was successfully set, `False` otherwise. 281 | 282 | **Example**: 283 | 284 | ```python 285 | from agentcomms import set_storage_path 286 | 287 | set_storage_path("/my/storage/directory") 288 | ``` 289 | 290 | ### `add_file(path, content)` 291 | 292 | Creates a file at the specified path and writes the provided content to it. 293 | 294 | **Arguments**: 295 | 296 | - `path` (str): The path to the new file. 297 | - `content` (str): The content to be written to the file. 298 | 299 | **Returns**: 300 | 301 | - `True` if the file was successfully created. 302 | 303 | **Example**: 304 | 305 | ```python 306 | from agentcomms import add_file 307 | 308 | add_file("test.txt", "Hello, world!") 309 | ``` 310 | 311 | ### `remove_file(path)` 312 | 313 | Removes the file at the specified path. 314 | 315 | **Arguments**: 316 | 317 | - `path` (str): The path to the file to be removed. 318 | 319 | **Returns**: 320 | 321 | - `True` if the file was successfully removed. 322 | 323 | **Example**: 324 | 325 | ```python 326 | from agentcomms import remove_file 327 | 328 | remove_file("test.txt") 329 | ``` 330 | 331 | ### `update_file(path, content)` 332 | 333 | Appends the provided content to the file at the specified path. 334 | 335 | **Arguments**: 336 | 337 | - `path` (str): The path to the file to be updated. 338 | - `content` (str): The content to be appended to the file. 339 | 340 | **Returns**: 341 | 342 | - `True` if the file was successfully updated. 343 | 344 | **Example**: 345 | 346 | ```python 347 | from agentcomms import update_file 348 | 349 | update_file("test.txt", "New content") 350 | ``` 351 | 352 | ### `list_files(path='.')` 353 | 354 | Lists all files in the specified directory. 355 | 356 | **Arguments**: 357 | 358 | - `path` (str, optional): The path to the directory. Defaults to `'.'` (current directory). 359 | 360 | **Returns**: 361 | 362 | - A list of file names in the specified directory. 363 | 364 | **Example**: 365 | 366 | ```python 367 | from agentcomms import list_files 368 | 369 | files = list_files() 370 | ``` 371 | 372 | ### `list_files_formatted(path='.')` 373 | 374 | Lists all files in the specified directory as a formatted string. Convenient! 375 | 376 | **Arguments**: 377 | 378 | - `path` (str, optional): The path to the directory. Defaults to `'.'` (current directory). 379 | 380 | **Returns**: 381 | 382 | - A string containing a list of file names in the specified directory. 383 | 384 | **Example**: 385 | 386 | ```python 387 | from agentcomms import list_files 388 | 389 | files = list_files() 390 | ``` 391 | 392 | ### `get_file(path)` 393 | 394 | Returns the content of the file at the specified path. 395 | 396 | **Arguments**: 397 | 398 | - `path` (str): The path to the file. 399 | 400 | **Returns**: 401 | 402 | - A string containing the content of the file. 403 | 404 | **Example**: 405 | 406 | ```python 407 | from agentcomms import get_file 408 | 409 | content = get_file("test.txt") 410 | ``` 411 | 412 | # Contributions Welcome 413 | 414 | If you like this library and want to contribute in any way, please feel free to submit a PR and I will review it. Please note that the goal here is simplicity and accesibility, using common language and few dependencies. 415 | -------------------------------------------------------------------------------- /agentcomms/__init__.py: -------------------------------------------------------------------------------- 1 | from .discord import * 2 | from .twitter import * -------------------------------------------------------------------------------- /agentcomms/adminpanel/__init__.py: -------------------------------------------------------------------------------- 1 | from .files import ( 2 | get_storage_path, 3 | set_storage_path, 4 | add_file, 5 | remove_file, 6 | update_file, 7 | list_files, 8 | list_files_formatted, 9 | get_file, 10 | ) 11 | from .server import ( 12 | start_server, 13 | get_server, 14 | set_storage_path, 15 | send_message, 16 | async_send_message, 17 | register_message_handler, 18 | unregister_message_handler, 19 | ) 20 | 21 | __all__ = [ 22 | "get_storage_path", 23 | "start_server", 24 | "send_message", 25 | "async_send_message", 26 | "register_message_handler", 27 | "unregister_message_handler", 28 | "get_server", 29 | "set_storage_path", 30 | "add_file", 31 | "remove_file", 32 | "update_file", 33 | "list_files", 34 | "list_files_formatted", 35 | "get_file", 36 | ] 37 | -------------------------------------------------------------------------------- /agentcomms/adminpanel/constants.py: -------------------------------------------------------------------------------- 1 | app = None 2 | 3 | storage_path = "./files/" 4 | -------------------------------------------------------------------------------- /agentcomms/adminpanel/files.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from agentcomms.adminpanel.constants import storage_path 4 | 5 | 6 | def check_files(): 7 | if not os.path.exists(storage_path): 8 | os.makedirs(storage_path, exist_ok=True) 9 | 10 | def get_storage_path(): 11 | return storage_path 12 | 13 | def set_storage_path(new_path): 14 | global storage_path 15 | if os.path.exists(new_path): # Check if the new_path exists 16 | storage_path = new_path 17 | return True 18 | else: 19 | return False # The new path doesn't exist 20 | 21 | 22 | def add_file(path, content): 23 | check_files() 24 | with open(os.path.join(storage_path, path), "w") as f: 25 | f.write(content) 26 | return True 27 | 28 | 29 | def remove_file(path): 30 | check_files() 31 | os.remove(os.path.join(storage_path, path)) # Removes the file 32 | return True 33 | 34 | 35 | def update_file(path, content): 36 | check_files() 37 | with open(os.path.join(storage_path, path), "a") as f: # 'a' for appending 38 | f.write(content) 39 | return True 40 | 41 | 42 | def list_files(path="."): 43 | check_files() 44 | return os.listdir(os.path.join(storage_path, path)) # Returns the list of files 45 | 46 | def list_files_formatted(path="."): 47 | check_files() 48 | files = os.listdir(os.path.join(storage_path, path)) 49 | files_formatted = [] 50 | for file in files: 51 | if os.path.isdir(os.path.join(storage_path, path, file)): 52 | files_formatted.append(os.path.join(storage_path, file + "/")) 53 | else: 54 | files_formatted.append(os.path.join(storage_path, file)) 55 | return "My Files:\n" + "\n".join(files_formatted) 56 | 57 | def get_file(path): 58 | check_files() 59 | with open(os.path.join(storage_path, path), "r") as f: # 'r' for reading 60 | content = f.read() 61 | return content 62 | -------------------------------------------------------------------------------- /agentcomms/adminpanel/page.py: -------------------------------------------------------------------------------- 1 | page = """\ 2 | 3 | 4 | 70 | 71 |
72 |
73 | 74 | 75 |
76 |
77 | 78 | 79 |
80 | 81 | 82 |
83 |
84 |
85 |
86 |
87 | 88 | 130 | 236 | 237 | 238 | """ 239 | -------------------------------------------------------------------------------- /agentcomms/adminpanel/server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import asyncio 4 | from fastapi import APIRouter, FastAPI, File, Form, HTTPException, UploadFile 5 | from fastapi.staticfiles import StaticFiles 6 | from pydantic import BaseModel 7 | 8 | from agentcomms.adminpanel.constants import app, storage_path 9 | from agentcomms.adminpanel.files import check_files, get_storage_path, set_storage_path 10 | from fastapi import FastAPI, WebSocket, WebSocketDisconnect 11 | from fastapi.middleware.cors import CORSMiddleware 12 | from fastapi.staticfiles import StaticFiles 13 | from fastapi.responses import FileResponse, HTMLResponse 14 | 15 | from agentcomms.adminpanel.page import page 16 | from concurrent.futures import ThreadPoolExecutor 17 | 18 | executor = ThreadPoolExecutor(max_workers=1) 19 | 20 | router = APIRouter() 21 | app = FastAPI() 22 | 23 | ws: WebSocket = None 24 | 25 | handlers = [] 26 | loop = None 27 | 28 | 29 | class FilePath(BaseModel): 30 | path: str 31 | 32 | 33 | def get_server(): 34 | """Retrieve the global FastAPI instance.""" 35 | global app 36 | return app 37 | 38 | 39 | def get_parent_path(): 40 | """Return the absolute path of the parent directory of this script.""" 41 | return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 42 | 43 | 44 | def start_server(storage_path=None, port=8000): 45 | """ 46 | Start the FastAPI server. 47 | 48 | :param storage_path: The path where to store the files. 49 | :param port: The port on which to start the server. 50 | :return: The FastAPI application. 51 | """ 52 | # start an event loop 53 | global loop 54 | loop = asyncio.new_event_loop() 55 | global app 56 | if storage_path: 57 | set_storage_path(storage_path) 58 | check_files() 59 | app.include_router(router) 60 | app.add_middleware( 61 | CORSMiddleware, 62 | allow_origins=["*"], 63 | allow_credentials=True, 64 | allow_methods=["*"], 65 | allow_headers=["*"], 66 | ) 67 | 68 | app.mount( 69 | "/files", StaticFiles(directory=get_storage_path(), html=False), name="files" 70 | ) 71 | if port: 72 | os.environ["PORT"] = str(port) 73 | 74 | return app 75 | 76 | 77 | def stop_server(): 78 | """Stop the FastAPI server by setting the global app to None.""" 79 | global app 80 | app = None 81 | 82 | 83 | @app.get("/") 84 | async def get(): 85 | """Handle a GET request to the root of the server, responding with an HTML page.""" 86 | return HTMLResponse(page) 87 | 88 | 89 | def send_message(message, type="chat", source="default"): 90 | """ 91 | Send a message to the websocket. 92 | 93 | :param message: The message to send. 94 | """ 95 | global ws 96 | global loop 97 | if ws is not None and loop is not None: 98 | message = json.dumps({"type": type, "message": message, "source": source}) 99 | asyncio.run(ws.send_text(message)) 100 | 101 | 102 | async def async_send_message(message, type="chat", source="default"): 103 | """ 104 | Send a message to the websocket. 105 | 106 | :param message: The message to send. 107 | """ 108 | global ws 109 | global loop 110 | if ws is not None and loop is not None: 111 | message = json.dumps({"type": type, "message": message, "source": source}) 112 | await ws.send_text(message) 113 | 114 | def register_message_handler(handler): 115 | """ 116 | Register a handler for messages received through the websocket. 117 | 118 | :param handler: The handler to register. 119 | """ 120 | global handlers 121 | handlers.append(handler) 122 | 123 | 124 | def unregister_message_handler(handler): 125 | """ 126 | Unregister a handler for messages received through the websocket. 127 | 128 | :param handler: The handler to unregister. 129 | """ 130 | global handlers 131 | handlers.remove(handler) 132 | 133 | 134 | @app.websocket("/ws") 135 | async def websocket_endpoint(websocket: WebSocket): 136 | """ 137 | Establish a websocket connection. 138 | 139 | :param websocket: The websocket through which to communicate. 140 | """ 141 | global ws 142 | ws = websocket 143 | await websocket.accept() 144 | try: 145 | while True: 146 | data = await websocket.receive_text() 147 | # data is a string, convert to json 148 | data = json.loads(data) 149 | for handler in handlers: 150 | await handler(data) 151 | except WebSocketDisconnect: 152 | ws = None 153 | 154 | 155 | @router.post("/file/") 156 | async def http_add_file(path: str = Form(...), file: UploadFile = File(...)): 157 | """ 158 | Create a new file at a given path with the provided content. 159 | 160 | :param path: The path where to create the file. 161 | :param file: The content to put in the file. 162 | :return: A success message. 163 | """ 164 | check_files() 165 | with open(os.path.join(storage_path, path), "wb") as f: 166 | f.write(await file.read()) 167 | return {"message": "File created"} 168 | 169 | 170 | @router.delete("/file/{path}") 171 | def http_remove_file(path: str): 172 | """ 173 | Delete a file at a given path. 174 | 175 | :param path: The path of the file to delete. 176 | :return: A success message. 177 | """ 178 | check_files() 179 | try: 180 | os.remove(os.path.join(storage_path, path)) 181 | return {"message": "File removed"} 182 | except Exception as e: 183 | raise HTTPException(status_code=400, detail=str(e)) 184 | 185 | 186 | @router.put("/file/") 187 | async def http_update_file(path: str = Form(...), file: UploadFile = File(...)): 188 | """ 189 | Update a file at a given path with the provided content. 190 | 191 | :param path: The path of the file to update. 192 | :param file: The new content to put in the file. 193 | :return: A success message. 194 | """ 195 | check_files() 196 | with open(os.path.join(storage_path, path), "wb") as f: 197 | f.write(await file.read()) 198 | return {"message": "File updated"} 199 | 200 | 201 | @router.get("/files/") 202 | def http_list_files(path: str = "."): 203 | """ 204 | List all files in a given directory. 205 | 206 | :param path: The path of the directory. 207 | :return: A list of files. 208 | """ 209 | check_files() 210 | return {"files": os.listdir(os.path.join(storage_path, path))} 211 | 212 | 213 | @router.get("/file/{path}") 214 | def http_get_file(path: str): 215 | """ 216 | Retrieve a file at a given path. 217 | 218 | :param path: The path of the file to retrieve. 219 | :return: The file as a response. 220 | """ 221 | check_files() 222 | try: 223 | return FileResponse(os.path.join(storage_path, path)) 224 | except Exception as e: 225 | raise HTTPException(status_code=400, detail=str(e)) -------------------------------------------------------------------------------- /agentcomms/adminpanel/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .files import * 2 | from .server import * -------------------------------------------------------------------------------- /agentcomms/adminpanel/tests/files.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from agentcomms.adminpanel.files import ( 4 | add_file, 5 | get_file, 6 | list_files, 7 | list_files_formatted, 8 | remove_file, 9 | set_storage_path, 10 | update_file, 11 | ) 12 | 13 | # Define a test directory 14 | TEST_DIR = "./test_dir/" 15 | 16 | 17 | def setup_module(): 18 | os.makedirs(TEST_DIR, exist_ok=True) 19 | set_storage_path(TEST_DIR) 20 | 21 | 22 | def teardown_module(): 23 | # check if TEST_DIR exists 24 | if not os.path.exists(TEST_DIR): 25 | return 26 | for file in os.listdir(TEST_DIR): 27 | os.remove(os.path.join(TEST_DIR, file)) 28 | os.rmdir(TEST_DIR) 29 | 30 | 31 | def test_add_file(): 32 | setup_module() 33 | assert add_file("test.txt", "Hello, world!") 34 | assert os.path.isfile(os.path.join(TEST_DIR, "test.txt")) 35 | teardown_module() 36 | 37 | 38 | def test_get_file(): 39 | setup_module() 40 | add_file("test.txt", "Hello, world!") 41 | assert get_file("test.txt") == "Hello, world!" 42 | teardown_module() 43 | 44 | 45 | def test_update_file(): 46 | setup_module() 47 | assert update_file("test.txt", "Hello, world! Updated") 48 | assert get_file("test.txt") == "Hello, world! Updated" 49 | teardown_module() 50 | 51 | 52 | def test_list_files(): 53 | setup_module() 54 | add_file("test.txt", "Hello, world!") 55 | assert "test.txt" in list_files() 56 | teardown_module() 57 | 58 | 59 | def test_list_files_formatted(): 60 | setup_module() 61 | add_file("test.txt", "Hello, world!") 62 | assert "test.txt" in list_files_formatted() 63 | teardown_module() 64 | 65 | 66 | def test_remove_file(): 67 | setup_module() 68 | add_file("test.txt", "Hello, world!") 69 | assert remove_file("test.txt") 70 | assert "test.txt" not in list_files() 71 | teardown_module() 72 | -------------------------------------------------------------------------------- /agentcomms/adminpanel/tests/server.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from agentcomms.adminpanel.server import start_server 3 | 4 | client = TestClient(start_server()) 5 | 6 | 7 | def test_http_add_file(): 8 | file_contents = "Hello, world!" 9 | response = client.post( 10 | "/file/", 11 | data={"path": "test.txt"}, 12 | files={"file": ("test.txt", file_contents, "text/plain")}, 13 | ) 14 | assert response.status_code == 200 15 | assert response.json() == {"message": "File created"} 16 | 17 | 18 | def test_http_get_file(): 19 | response = client.get("/file/test.txt") 20 | assert response.status_code == 200 21 | assert response.content.decode() == "Hello, world!" 22 | 23 | 24 | def test_http_update_file(): 25 | file_contents = " Updated" 26 | response = client.put( 27 | "/file/", 28 | data={"path": "test.txt"}, 29 | files={"file": ("test.txt", file_contents, "text/plain")}, 30 | ) 31 | assert response.status_code == 200 32 | assert response.json() == {"message": "File updated"} 33 | 34 | 35 | def test_http_list_files(): 36 | test_http_add_file() 37 | response = client.get("/files/") 38 | assert response.status_code == 200 39 | assert "test.txt" in response.json()["files"] 40 | 41 | 42 | def test_http_remove_file(): 43 | response = client.delete("/file/test.txt") 44 | assert response.status_code == 200 45 | assert response.json() == {"message": "File removed"} 46 | -------------------------------------------------------------------------------- /agentcomms/discord/__init__.py: -------------------------------------------------------------------------------- 1 | from .connector import ( 2 | start_connector as start_discord_connector, 3 | send_message, 4 | register_feed_handler, 5 | unregister_feed_handler, 6 | ) 7 | 8 | __all__ = [ 9 | "start_discord_connector", 10 | "send_message", 11 | "register_feed_handler", 12 | "unregister_feed_handler", 13 | ] 14 | -------------------------------------------------------------------------------- /agentcomms/discord/actions.py: -------------------------------------------------------------------------------- 1 | # actions for send_message, send_dm, join_channel and leave_channel -------------------------------------------------------------------------------- /agentcomms/discord/connector.py: -------------------------------------------------------------------------------- 1 | import discord as discord_py 2 | import os 3 | import openai 4 | import time 5 | import asyncio 6 | from random import * 7 | from elevenlabs import set_api_key, generate, save 8 | from dotenv import load_dotenv 9 | from threading import Thread 10 | 11 | load_dotenv() 12 | 13 | message_queue = asyncio.Queue() 14 | 15 | temp_folder_tts = "./temp" 16 | temp_path = "./temp/output.mp3" 17 | 18 | intents = discord_py.Intents().all() 19 | intents.message_content = True 20 | bot = discord_py.Client(intents=intents) 21 | vc = None 22 | 23 | 24 | async def send_queued_messages(): 25 | """ 26 | Function to send all messages that have been queued. 27 | """ 28 | while True: 29 | print("Waiting for message") 30 | message, channel_id = await message_queue.get() 31 | channel = bot.get_channel(channel_id) 32 | if channel is not None: 33 | # Send the message 34 | # determine if this is a voice channel 35 | if channel.type == discord_py.ChannelType.voice: 36 | try: 37 | sources = [] 38 | sentences = message.split("\n") 39 | for sentence in sentences: 40 | tts_reply = generate_tts(str(sentence)) 41 | sources.append(tts_reply) 42 | for source in sources: 43 | vc.play(discord_py.FFmpegPCMAudio(source)) 44 | while vc.is_playing(): 45 | await asyncio.sleep(0.10) 46 | except: 47 | raise 48 | else: 49 | await channel.send(message) 50 | 51 | # Mark the message as handled 52 | message_queue.task_done() 53 | print("Message sent from queue") 54 | 55 | 56 | def send_message(message, channel_id): 57 | """ 58 | Function to send a message. Adds the message to the queue. 59 | 60 | Args: 61 | message (str): Message to send 62 | channel_id (int): ID of the channel where the message will be sent 63 | """ 64 | message_queue.put_nowait((message, channel_id)) 65 | print("Message sent") 66 | 67 | 68 | def start_connector(loop_dict=None, discord_api_token=None): 69 | """ 70 | Starts the Discord bot connector 71 | 72 | Args: 73 | discord_api_token (str, optional): Discord API token. Defaults to None. 74 | """ 75 | global bot 76 | print("Starting Discord connector") 77 | if discord_api_token is None: 78 | discord_api_token = os.getenv("DISCORD_API_TOKEN") 79 | print("Discord connector started") 80 | 81 | def bot_thread(): 82 | loop = asyncio.new_event_loop() 83 | asyncio.set_event_loop(loop) 84 | 85 | async def main(): 86 | await bot.start(discord_api_token) 87 | 88 | loop.run_until_complete(asyncio.gather(main(), send_queued_messages())) 89 | 90 | # Create a new thread that will run the bot 91 | t = Thread(target=bot_thread, daemon=True) 92 | t.start() 93 | return t 94 | 95 | 96 | def generate_tts(message): 97 | """ 98 | Generates TTS for a given message. 99 | 100 | Args: 101 | message (str): Message to convert to speech. 102 | 103 | Returns: 104 | str: File path of the audio file. 105 | """ 106 | set_api_key(os.getenv("ELEVENLABS_API_KEY")) 107 | voice = os.getenv("ELEVENLABS_VOICE") 108 | model = os.getenv("ELEVENLABS_MODEL") 109 | timestamp = str(time.time()) 110 | # check that temp_folder_tts exists 111 | if not os.path.exists(temp_folder_tts): 112 | os.makedirs(temp_folder_tts) 113 | file_path = temp_folder_tts + "/reply_" + timestamp + ".mp3" 114 | print(message) 115 | audio = generate(text=message, voice=voice, model=model, stream=False) 116 | save(audio, file_path) 117 | return file_path 118 | 119 | 120 | handlers = [] 121 | 122 | 123 | def register_feed_handler(func): 124 | """ 125 | Registers a new feed handler. 126 | 127 | Args: 128 | func (callable): Function to be registered as a handler. 129 | """ 130 | handlers.append(func) 131 | 132 | 133 | def unregister_feed_handler(func): 134 | """ 135 | Unregisters a feed handler. 136 | 137 | Args: 138 | func (callable): Function to be unregistered as a handler. 139 | """ 140 | handlers.remove(func) 141 | 142 | 143 | @bot.event 144 | async def on_ready(): 145 | """ 146 | Callback function that is called when the bot is ready. 147 | """ 148 | print(f"{bot.user} has connected to Discord!") 149 | 150 | 151 | @bot.event 152 | async def on_message(message): 153 | """ 154 | Callback function that is called when a new message is received. 155 | 156 | Args: 157 | message (Message): Message object. 158 | """ 159 | global vc 160 | if message.author == bot.user: 161 | return 162 | 163 | if not message.guild.me.permissions_in(message.channel).manage_messages: 164 | print("Missing permissions to manage messages") 165 | return 166 | 167 | if discord_py.utils.get(bot.voice_clients, guild=message.guild) != None: 168 | if ( 169 | discord_py.utils.get(bot.voice_clients, guild=message.guild).is_playing() 170 | == True 171 | ): 172 | await message.delete() 173 | return 174 | 175 | # Execute registered handlers 176 | for handler in handlers: 177 | await handler(message) 178 | 179 | contents = message.content 180 | speaker_id = contents 181 | await message.delete() 182 | channel = message.channel 183 | members = channel.members 184 | 185 | for themember in members: 186 | if themember.id == int(speaker_id): 187 | voice = themember.voice 188 | 189 | voice_client = discord_py.utils.get(bot.voice_clients, guild=voice.channel.guild) 190 | if voice_client is None: 191 | vc = await voice.channel.connect() 192 | else: 193 | vc = voice_client 194 | 195 | if os.path.exists(temp_path): 196 | os.remove(temp_path) 197 | 198 | # Assuming you have a start_recording method implemented in your voice client 199 | vc.start_recording( 200 | discord_py.sinks.MP3Sink(), 201 | vgpt_after, 202 | voice.channel, 203 | ) 204 | 205 | await asyncio.sleep(5) 206 | vc.stop_recording() 207 | 208 | while not os.path.exists(temp_path): 209 | await asyncio.sleep(0.1) 210 | audio_file = open(temp_path, "rb") 211 | openai.api_key = os.getenv("OPENAI_API_KEY") 212 | transcript = openai.Audio.transcribe("whisper-1", audio_file) 213 | transcript = str(transcript.text) 214 | 215 | if transcript == "ok": 216 | return 217 | 218 | message["speaker_id"] = speaker_id 219 | message["transcript"] = transcript 220 | message["vc"] = vc 221 | 222 | for handler in handlers: 223 | # if handler is async, await it 224 | if asyncio.iscoroutinefunction(handler): 225 | await handler(message) 226 | else: 227 | handler(message) 228 | return message 229 | 230 | 231 | async def vgpt_after(sink: discord_py.sinks, channel: discord_py.TextChannel, *args): 232 | """ 233 | Post-processing function to be executed after receiving a message. 234 | 235 | Args: 236 | sink (discord_py.sinks): Audio sink. 237 | channel (discord_py.TextChannel): Channel where the message was received. 238 | args: Additional arguments. 239 | """ 240 | user_id = "" 241 | for user_id, audio in sink.audio_data.items(): 242 | user_id = f"<@{user_id}>" 243 | with open(temp_path, "wb") as f: 244 | f.write(audio.file.getbuffer()) 245 | -------------------------------------------------------------------------------- /agentcomms/discord/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .tests import * -------------------------------------------------------------------------------- /agentcomms/discord/tests/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from agentcomms.discord import start_discord_connector 4 | from dotenv import load_dotenv 5 | from elevenlabs import set_api_key 6 | 7 | from agentcomms.discord.connector import send_message 8 | 9 | load_dotenv() 10 | voice = os.getenv("ELEVENLABS_VOICE") 11 | model = os.getenv("ELEVENLABS_MODEL") 12 | set_api_key(os.getenv("ELEVENLABS_API_KEY")) 13 | 14 | # def test_generate_tts(): 15 | # message = "Hello, world!" 16 | 17 | # file_path = generate_tts(message) 18 | 19 | # # Check that the file path is constructed correctly 20 | # assert file_path.startswith("./temp/reply_") 21 | # assert file_path.endswith(".mp3") 22 | 23 | # # Check that the file has been created 24 | # assert os.path.exists(file_path) 25 | 26 | # print("TTS file generated successfully") 27 | 28 | # # Additional assertions could check the contents of the file or other properties 29 | 30 | 31 | def test_start_discord_connector(capfd): 32 | # Call start_connector function 33 | thread = start_discord_connector() 34 | time.sleep(2) 35 | send_message("Hello, world!", 1107883421759447040) 36 | time.sleep(2) 37 | # Check the print output 38 | captured = capfd.readouterr() 39 | assert "Starting Discord connector" in captured.out 40 | assert "Discord connector started" in captured.out 41 | print(captured.out) 42 | 43 | # Stop the bot if you need to 44 | # loop = asyncio.get_event_loop() 45 | # loop.run_until_complete(bot.close()) 46 | -------------------------------------------------------------------------------- /agentcomms/twitter/__init__.py: -------------------------------------------------------------------------------- 1 | from .connector import ( 2 | start_connector as start_twitter_connector, 3 | start_twitter, 4 | like_tweet, 5 | reply_to_tweet, 6 | tweet, 7 | search_tweets, 8 | get_authors, 9 | get_relevant_tweets_from_author_timeline, 10 | register_feed_handler, 11 | unregister_feed_handler, 12 | get_account 13 | ) 14 | 15 | __all__ = [ 16 | "start_twitter_connector", 17 | "register_feed_handler", 18 | "unregister_feed_handler", 19 | "start_twitter", 20 | "like_tweet", 21 | "reply_to_tweet", 22 | "tweet", 23 | "get_account", 24 | "search_tweets", 25 | "get_authors" 26 | ] 27 | -------------------------------------------------------------------------------- /agentcomms/twitter/actions.py: -------------------------------------------------------------------------------- 1 | # actions for send_message, reply_to and like -------------------------------------------------------------------------------- /agentcomms/twitter/connector.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pdb 3 | import asyncio 4 | from pathlib import Path 5 | import random 6 | import threading 7 | import time 8 | from httpx import Client 9 | from twitter.account import Account 10 | from twitter.scraper import Scraper 11 | from twitter.search import Search 12 | import orjson 13 | 14 | from twitter.util import init_session 15 | 16 | from dotenv import load_dotenv 17 | 18 | load_dotenv() 19 | 20 | first = True 21 | 22 | params = { 23 | "include_profile_interstitial_type": 1, 24 | "include_blocking": 1, 25 | "include_blocked_by": 1, 26 | "include_followed_by": 1, 27 | "include_want_retweets": 1, 28 | "include_mute_edge": 1, 29 | "include_can_dm": 1, 30 | "include_can_media_tag": 1, 31 | "include_ext_has_nft_avatar": 1, 32 | "include_ext_is_blue_verified": 1, 33 | "include_ext_verified_type": 1, 34 | "include_ext_profile_image_shape": 1, 35 | "skip_status": 1, 36 | "cards_platform": "Web-12", 37 | "include_cards": 1, 38 | "include_ext_alt_text": "true", 39 | "include_ext_limited_action_results": "true", 40 | "include_quote_count": "true", 41 | "include_reply_count": 1, 42 | "tweet_mode": "extended", 43 | "include_ext_views": "true", 44 | "include_entities": "true", 45 | "include_user_entities": "true", 46 | "include_ext_media_color": "true", 47 | "include_ext_media_availability": "true", 48 | "include_ext_sensitive_media_warning": "true", 49 | "include_ext_trusted_friends_metadata": "true", 50 | "send_error_codes": "true", 51 | "simple_quoted_tweet": "true", 52 | "count": 20, 53 | "requestContext": "launch", 54 | "ext": "mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,birdwatchPivot,superFollowMetadata,unmentionInfo,editControl", 55 | } 56 | 57 | feed_handlers = [] 58 | dm_handlers = [] 59 | 60 | account = None 61 | search = None 62 | 63 | def get_account(): 64 | """ 65 | Returns the global account object. 66 | """ 67 | return account 68 | 69 | 70 | def like_tweet(tweet_id): 71 | """ 72 | Likes a tweet with the given tweet ID using the global account object. 73 | 74 | Parameters: 75 | tweet_id (str): The ID of the tweet to be liked. 76 | """ 77 | account.like(tweet_id) 78 | 79 | 80 | def reply_to_tweet(message, tweet_id): 81 | """ 82 | Replies to a tweet with the given message and tweet ID using the global account object. 83 | 84 | Parameters: 85 | message (str): The message to be sent as a reply. 86 | tweet_id (str): The ID of the tweet to reply to. 87 | """ 88 | account.reply(message, tweet_id) 89 | 90 | 91 | def tweet(message, media=None): 92 | """ 93 | Posts a new tweet with the given message and optional media using the global account object. 94 | 95 | Parameters: 96 | message (str): The message to be tweeted. 97 | media (str, optional): The media to be attached to the tweet. Defaults to None. 98 | """ 99 | if media: 100 | account.tweet(message, media) 101 | else: 102 | account.tweet(message) 103 | 104 | 105 | def fetch_data(query, limit, retries): 106 | """ 107 | Fetches data based on the provided search query. 108 | 109 | Args: 110 | - query (str): The search query. 111 | - limit (int): Maximum number of results to return. 112 | - retries (int): Number of times to retry the search in case of failure. 113 | 114 | Returns: 115 | - list[dict]: List of search results. 116 | """ 117 | queries = [ 118 | { 119 | 'category': 'Latest', 120 | 'query': query 121 | }, 122 | { 123 | 'category': 'Top', 124 | 'query': query 125 | } 126 | ] 127 | results = search.run(limit=limit, retries=retries, queries=queries) 128 | results = [data for result in results for data in result] 129 | return results 130 | 131 | def search_tweets(topic, min_results=30, max_retries=5, **filters): 132 | """ 133 | Searches for tweets based on provided criteria and returns a filtered list. 134 | 135 | Parameters: 136 | topic (str): The primary search term or phrase. 137 | min_results (int, optional): Minimum desired number of results. Default is 30. 138 | max_retries (int, optional): Maximum number of retries in case of search failures. Default is 5. 139 | filters (dict, optional): Additional parameters to filter the search results. 140 | 141 | Returns: 142 | list: A list of tweets that match the search criteria. 143 | 144 | TODO: 145 | - Enhance query structure: 146 | - Support advanced query operations (e.g., AND, OR). 147 | - Implement exact phrase matching and exclusion of terms. 148 | - Integrate time-based filters for refined search. 149 | - Incorporate Twitter Spaces transcriptions for deeper content insights. 150 | - Develop logic for handling retweets and associated content nuances. 151 | """ 152 | 153 | # Extracting and constructing search filters 154 | min_faves = filters.get('min_faves', 0) 155 | min_retweets = filters.get('min_retweets', 0) 156 | min_replies = filters.get('min_replies', 0) 157 | verified_only = filters.get('verified_only', False) 158 | 159 | # TODO direct mapping for now. 160 | # TODO Generate queries from topic similar to above. 161 | query = topic 162 | # Appending filters to the base query 163 | if min_faves: 164 | query += f" min_faves:{min_faves}" 165 | if min_retweets: 166 | query += f" min_retweets:{min_retweets}" 167 | if min_replies: 168 | query += f" min_replies:{min_replies}" 169 | if verified_only: 170 | query += " filter:verified" 171 | 172 | results = fetch_data(query, min_results, max_retries) 173 | # concatenation hack 174 | return results 175 | 176 | 177 | def get_authors(tweets_data, **filters): 178 | """ 179 | Extracts a list of authors from the provided tweet data based on specified filters. 180 | 181 | Parameters: 182 | tweets_data (list[dict]): A list of dictionaries representing tweet data. 183 | filters (dict): Optional filters to refine the list of authors, including 'follower_count_min', 'search_keywords', and 'verified_only'. 184 | 185 | Returns: 186 | dict: A dictionary mapping screen names to author data. 187 | """ 188 | 189 | def calculate_impact_factor(tweet_impact_data): 190 | """Compute the impact factor based on various tweet metrics.""" 191 | return sum([ 192 | tweet_impact_data.get(metric, 0) 193 | for metric in ['bookmark_count', 'favorite_count', 'reply_count', 'retweet_count'] 194 | ]) 195 | 196 | # Extract filters 197 | follower_count_min = filters.get('follower_count_min', 0) 198 | verified_only = filters.get('verified_only', False) 199 | 200 | authors = {} 201 | 202 | for tweet_data in tweets_data: 203 | # Extract relevant data points 204 | pdb.set_trace() 205 | tweet_result = tweet_data.get('content', {}).get('itemContent', {}).get('tweet_results', {}).get('result', {}) 206 | user_data_core = tweet_result.get('core', {}).get('user_results', {}).get('result', {}) 207 | user_data = user_data_core.get('legacy', {}) 208 | tweet_impact_data = tweet_result.get('legacy', {}) 209 | tweet_impact_data['views'] = tweet_result.get('views', {}) 210 | 211 | # Checks 212 | if "tweet" not in tweet_data.get('entryId', '') or not user_data or not tweet_impact_data: 213 | continue 214 | if verified_only and not user_data_core.get('is_blue_verified', False): 215 | continue 216 | if user_data.get('followers_count', 0) < follower_count_min: 217 | continue 218 | 219 | # Calculate impact factor 220 | impact_factor = calculate_impact_factor(tweet_impact_data) 221 | 222 | # Extract and accumulate author data 223 | author_info = { 224 | 'id': user_data.get('id'), 225 | 'name': user_data.get('name'), 226 | 'screen_name': user_data.get('screen_name'), 227 | 'followers_count': user_data.get('followers_count'), 228 | 'description': user_data.get('description'), 229 | 'favourites_count': user_data.get('favourites_count'), 230 | 'friends_count': user_data.get('friends_count'), 231 | 'normal_follower_count': user_data.get('normal_followers_count'), 232 | 'views': int(tweet_impact_data.get('views', {}).get('count', 0)), 233 | 'impact_factor': impact_factor, 234 | } 235 | author_url = author_info['screen_name'] 236 | if author_url not in authors: 237 | authors[author_url] = author_info 238 | else: 239 | # pdb.set_trace() 240 | authors[author_url]['views'] += int(author_info['views']) 241 | authors[author_url]['impact_factor'] += author_info['impact_factor'] 242 | 243 | # TODO Further enhancements: Normalize impact factor, consider tweet age/time, etc. 244 | return authors 245 | 246 | 247 | """ 248 | CHATGPT PROMPT: 249 | I want to search tweets to rank authors who write about a particular topic, you are my `research and media assistant`. Given a topic, give me relevant queries I should search for on twitter. The queries should be ranked based on usage. 250 | 251 | topic: 252 | AI Existential Risk 253 | queries: 254 | 255 | REPLY: 256 | For the topic "AI Existential Risk," here's a list of potential queries ranked based on usage and relevance: 257 | 258 | 1. "AI Existential Risk" 259 | 2. "Artificial Intelligence existential threat" 260 | 3. "AI and global catastrophic risks" 261 | 4. "Dangers of superintelligent AI" 262 | 5. "Risks of advanced AI" 263 | 6. "Long-term AI safety concerns" 264 | 7. "Unintended consequences of AI" 265 | 8. "AI and end of humanity concerns" 266 | 9. "Machine learning existential threats" 267 | 10. "Ethical concerns of powerful AI" 268 | 269 | These queries encapsulate various ways people might discuss the existential risks of AI on Twitter. They range from the direct ("AI Existential Risk") to the more nuanced or indirect ("Ethical concerns of powerful AI"). 270 | """ 271 | # TODO Generate queries from topic similar to above. 272 | 273 | def extract_document_from_tweet(tweet_data): 274 | """ 275 | Extracts relevant information from a tweet to construct a document. 276 | 277 | Args: 278 | - tweet_data (dict): The raw tweet data. 279 | 280 | Returns: 281 | - str: A formatted document containing relevant information from the tweet. 282 | """ 283 | tweet_result = tweet_data.get('content', {}).get('itemContent', {}).get('tweet_results', {}).get('result', {}) 284 | user_data = tweet_result.get('core', {}).get('user_results', {}).get('result', {}).get('legacy', {}) 285 | 286 | return (f"author_name: {user_data.get('name')}\n" 287 | f"screen_name: {user_data.get('screen_name')}\n" 288 | f"full_text: {tweet_result.get('legacy', {}).get('full_text', '')}\n" 289 | f"created_at: {tweet_result.get('legacy', {}).get('created_at', '')}\n\n\n") 290 | 291 | 292 | def get_relevant_tweets_from_author_timeline(topic, author, min_results=30, max_retries=5, **filters): 293 | """ 294 | Fetches and processes tweets related to a given topic from a specific author's timeline. 295 | 296 | Args: 297 | - topic (str): The main topic or keyword for which tweets are to be fetched. 298 | - author (str): The screen name of the Twitter user. 299 | - min_results (int): Minimum number of results to return per query. 300 | - max_retries (int): Number of times to retry the search in case of failure. 301 | 302 | Returns: 303 | - dict: A dictionary where the key is the conversation_id and the value is a document constructed from all relevant tweets. 304 | """ 305 | query = topic + f" (from:{author})" 306 | tweets_data = fetch_data(query, min_results, max_retries) 307 | 308 | documents = {} 309 | 310 | for tweet_data in tweets_data: 311 | pdb.set_trace() 312 | tweet_result = tweet_data.get('content', {}).get('itemContent', {}).get('tweet_results', {}).get('result', {}) 313 | conversation_id = tweet_result['legacy']['conversation_id_str'] 314 | 315 | if conversation_id in documents: 316 | continue 317 | pdb.set_trace() 318 | quote_tweet_id = int(tweet_result.get('legacy', {}).get('quoted_status_id_str', 0)) 319 | conversation_data = fetch_data(f"(conversation_id:{conversation_id})", min_results, max_retries) 320 | try: 321 | origin_tweet_data = [scraper.tweets_details([int(conversation_id)])[0]['data']['threaded_conversation_with_injections_v2']['instructions'][0]['entries'][0]] 322 | # TODO handle case where origin_tweet has quote_tweet 323 | # use origin_tweet_data to update quote_tweet_id from 0 to x. 324 | quote_tweet_data = [scraper.tweets_details([quote_tweet_id])[0]['data']['threaded_conversation_with_injections_v2']['instructions'][0]['entries'][0]] if quote_tweet_id else [] 325 | except Exception as e: 326 | print(f"An error occurred: {e}") 327 | print("Error: Error scraping for origin_tweet_data and quote_tweet_data") 328 | origin_tweet_data = [] 329 | quote_tweet_data = [] 330 | all_tweets_in_conversation = origin_tweet_data + quote_tweet_data + conversation_data 331 | documents[conversation_id] = ''.join([extract_document_from_tweet(data) for data in all_tweets_in_conversation]) 332 | 333 | return documents 334 | 335 | def register_feed_handler(handler): 336 | """ 337 | Registers a new feed handler function. The handler function will be called whenever new feed messages are received. 338 | 339 | Parameters: 340 | handler (function): The function to be registered as a feed handler. 341 | """ 342 | feed_handlers.append(handler) 343 | 344 | 345 | def unregister_feed_handler(handler): 346 | """ 347 | Unregisters a feed handler function. The handler will no longer be called when new feed messages are received. 348 | 349 | Parameters: 350 | handler (function): The function to be unregistered. 351 | """ 352 | feed_handlers.remove(handler) 353 | 354 | 355 | # async def dm_loop(account, session, scraper): 356 | # """ 357 | # Main DM loop. This function checks for new DMs and passes them to all registered feed handlers. 358 | 359 | # Parameters: 360 | # account (twitter.account.Account): The account object to be used for the DM operations. 361 | # session (httpx.Client): The httpx session to be used for the DM operations. 362 | # scraper (twitter.scraper.Scraper): The Scraper object to be used for the DM operations. 363 | # """ 364 | # last_responded_notification = None 365 | # global params 366 | 367 | # while True: 368 | # inbox = account.dm_inbox() 369 | # for handler in feed_handlers: 370 | # arguments = { 371 | # "inbox": inbox, 372 | # "account": account, 373 | # "session": session, 374 | # } 375 | 376 | # # if the handler is async, await it, otherwise call directly 377 | # if asyncio.iscoroutinefunction(handler): 378 | # await handler(arguments) 379 | # else: 380 | # handler(arguments) 381 | 382 | # await asyncio.sleep(10 + random.randint(0, 2)) 383 | 384 | 385 | def feed_loop(account, session): 386 | """ 387 | Main feed loop. This function checks for new tweets and passes them to all registered feed handlers. 388 | 389 | Parameters: 390 | account (twitter.account.Account): The account object to be used for the feed operations. 391 | session (httpx.Client): The httpx session to be used for the feed operations. 392 | """ 393 | last_responded_notification = None 394 | global params 395 | global first 396 | cursor = None 397 | 398 | while True: 399 | if first == True: 400 | params["count"] = 10 401 | del params["requestContext"] 402 | first = False 403 | time.sleep(7) 404 | continue 405 | # Parse the JSON response 406 | notifications = account.notifications() 407 | globalObjects = notifications.get("globalObjects", {}) 408 | object_notifications = globalObjects.get("notifications", {}) 409 | instructions = notifications["timeline"]["instructions"] 410 | new_entries = [] 411 | for x in instructions: 412 | addEntries = x.get("addEntries") 413 | if addEntries: 414 | entries = addEntries["entries"] 415 | new_entries.append(entries) 416 | for x in entries: 417 | entryId = x["entryId"] 418 | find_cursor_top = entryId.find("cursor-top") 419 | if find_cursor_top != -1: 420 | content = x["content"] 421 | operation = content.get("operation") 422 | if operation: 423 | cursor = operation["cursor"]["value"] 424 | if cursor: 425 | params["cursor"] = cursor 426 | 427 | # Extract all tweet notifications from the response 428 | tweet_notifications = [ 429 | notification 430 | for notification in object_notifications.values() 431 | if "text" in notification["message"] 432 | ] 433 | 434 | # Sort notifications by timestamp (newest first) 435 | tweet_notifications.sort(key=lambda x: x["timestampMs"], reverse=True) 436 | tweet_id = None 437 | for notification in tweet_notifications: 438 | # if print(notification["message"]["text"]) includes "There was a login", continue 439 | if ( 440 | "There was a login" in notification["message"]["text"] 441 | or "liked a Tweet you" in notification["message"]["text"] 442 | ): 443 | continue 444 | 445 | # Skip notifications we've already responded to 446 | if ( 447 | last_responded_notification is not None 448 | and notification["timestampMs"] <= last_responded_notification 449 | ): 450 | continue 451 | 452 | targetObjects = ( 453 | notification.get("template", {}) 454 | .get("aggregateUserActionsV1", {}) 455 | .get("targetObjects", [{}]) 456 | ) 457 | if len(targetObjects) > 0: 458 | tweet_id = targetObjects[0].get("tweet", {}).get("id") 459 | 460 | tweet_details = scraper.tweets_details([tweet_id]) 461 | 462 | # get the tweet 463 | 464 | arguments = { 465 | "tweet_id": tweet_id, 466 | "notification": notification, 467 | "tweet": tweet_details[0], 468 | "account": account, 469 | "session": session, 470 | } 471 | 472 | # call response handlers here with the notification as the argument 473 | for handler in feed_handlers: 474 | # if the handler is async, await it, otherwise call directly 475 | if asyncio.iscoroutinefunction(handler): 476 | asyncio.run(handler(arguments)) 477 | else: 478 | handler(arguments) 479 | 480 | # Update the latest responded notification timestamp 481 | last_responded_notification = notification["timestampMs"] 482 | 483 | time.sleep(10 + random.randint(0, 2)) 484 | 485 | 486 | def start_twitter( 487 | email=None, 488 | username=None, 489 | password=None, 490 | session_storage_path="twitter.cookies", 491 | start_loop=True, 492 | loop_dict=None, 493 | ): 494 | """ 495 | Starts the Twitter connector. Initializes the global account object and starts the feed loop in a new thread. 496 | 497 | Parameters: 498 | email (str, optional): The email address to be used for the Twitter account. If not provided, will be loaded from environment variables. 499 | username (str, optional): The username to be used for the Twitter account. If not provided, will be loaded from environment variables. 500 | password (str, optional): The password to be used for the Twitter account. If not provided, will be loaded from environment variables. 501 | session_storage_path (str, optional): The path where session data should be stored. Defaults to "twitter.cookies". 502 | start_loop (bool, optional): Whether the feed loop should be started immediately. Defaults to True. 503 | 504 | Returns: 505 | threading.Thread: The thread where the feed loop is running. 506 | """ 507 | global account 508 | global search 509 | global scraper 510 | # get the environment variables 511 | if email is None: 512 | email = os.getenv("TWITTER_EMAIL") 513 | if username is None: 514 | username = os.getenv("TWITTER_USERNAME") 515 | if password is None: 516 | password = os.getenv("TWITTER_PASSWORD") 517 | 518 | session_file = Path(session_storage_path) 519 | session = None 520 | 521 | if session_file.exists(): 522 | cookies = orjson.loads(session_file.read_bytes()) 523 | session = Client(cookies=cookies) 524 | account = Account(session=session) 525 | scraper = Scraper(session=session) 526 | else: 527 | session = init_session() 528 | account = Account(email=email, username=username, password=password) 529 | scraper = Scraper(session=session) 530 | cookies = { 531 | k: v 532 | for k, v in account.session.cookies.items() 533 | if k in {"ct0", "auth_token"} 534 | } 535 | session_file.write_bytes(orjson.dumps(cookies)) 536 | 537 | search = Search(email=email, username=username, password=password, save=True, debug=1) 538 | 539 | if start_loop: 540 | thread = threading.Thread(target=feed_loop, args=(account, session, scraper)) 541 | thread.start() 542 | return thread 543 | 544 | 545 | def start_connector( 546 | loop_dict=None, # dict of information for stopping the loop, etc 547 | email=None, 548 | username=None, 549 | password=None, 550 | session_storage_path="twitter.cookies", 551 | start_loop=True, 552 | ): 553 | """ 554 | Convenience function to start the Twitter connector. This function calls start_twitter with the default arguments. 555 | """ 556 | start_twitter( 557 | email=None, 558 | username=None, 559 | password=None, 560 | session_storage_path="twitter.cookies", 561 | start_loop=True, 562 | loop_dict=None, 563 | ) 564 | -------------------------------------------------------------------------------- /agentcomms/twitter/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .tests import * -------------------------------------------------------------------------------- /agentcomms/twitter/tests/tests.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import json 4 | import pdb 5 | from agentcomms.twitter import ( 6 | start_twitter, 7 | like_tweet, 8 | reply_to_tweet, 9 | tweet, 10 | search_tweets, 11 | get_authors, 12 | get_relevant_tweets_from_author_timeline, 13 | register_feed_handler, 14 | unregister_feed_handler, 15 | ) 16 | 17 | # Define constants or load them from environment variables 18 | TWITTER_EMAIL = os.getenv("TWITTER_EMAIL") 19 | TWITTER_USERNAME = os.getenv("TWITTER_USERNAME") 20 | TWITTER_PASSWORD = os.getenv("TWITTER_PASSWORD") 21 | SESSION_STORAGE_PATH = "twitter.cookies" 22 | 23 | 24 | 25 | # Custom setup function 26 | def setup_function(): 27 | # Call the start_connector function and check behavior (this is likely to have side effects) 28 | start_twitter( 29 | email=TWITTER_EMAIL, 30 | username=TWITTER_USERNAME, 31 | password=TWITTER_PASSWORD, 32 | session_storage_path=SESSION_STORAGE_PATH, 33 | start_loop=False, 34 | ) 35 | 36 | 37 | # Add more tests as required for other functions and behaviors 38 | def test_like_tweet(): 39 | setup_function() 40 | tweet_id = "1687657490584928256" # Replace with actual tweet ID 41 | like_tweet(tweet_id) 42 | 43 | 44 | def test_reply_to_tweet(): 45 | setup_function() 46 | message = "Test reply!" 47 | tweet_id = "1687657490584928256" # Replace with actual tweet ID 48 | reply_to_tweet(message, tweet_id) 49 | 50 | 51 | def test_tweet(): 52 | setup_function() 53 | message = "Test tweet!" 54 | tweet(message) 55 | 56 | 57 | def test_search_tweets(topic="Upstreet", limit=100, **kwargs): 58 | setup_function() 59 | res = search_tweets(topic, limit, retries=5, **kwargs) 60 | return res 61 | 62 | 63 | def test_get_authors(tweets_data=None, **kwargs): 64 | setup_function() 65 | if tweets_data is None: 66 | tweets_data = test_search_tweets() 67 | pdb.set_trace() 68 | authors = get_authors(tweets_data) 69 | authors = dict(sorted(authors.items(), key=lambda x: x[1]['impact_factor'], reverse=True)) 70 | return authors 71 | 72 | 73 | def test_get_relevant_tweets_from_author_timeline(topic=None, author=None): 74 | setup_function() 75 | if topic is None and author is None: 76 | topic = "Attention" 77 | author = "GenZSiv" 78 | documents = get_relevant_tweets_from_author_timeline(topic, author) 79 | return documents 80 | 81 | 82 | def test_register_and_unregister_feed_handler(): 83 | setup_function() 84 | handler = lambda x: print(x) 85 | register_feed_handler(handler) 86 | # Here you can add logic to verify the handler was registered, such as checking the feed_handlers list 87 | unregister_feed_handler(handler) 88 | 89 | 90 | if __name__=='__main__': 91 | 92 | topic = "Attention" 93 | author = "GenZSiv" 94 | 95 | tweets_data = test_search_tweets(topic , 5) 96 | authors = test_get_authors(tweets_data) 97 | 98 | with open(f'{topic}_data.json', 'w') as file: 99 | json.dump(tweets_data, file, indent=4) 100 | 101 | with open(f'{topic}_authors.json', 'w') as file: 102 | json.dump(authors, file, indent=4) 103 | 104 | documents = test_get_relevant_tweets_from_author_timeline(topic, author) 105 | 106 | with open(f'documents_{author}_{topic}.json', 'w') as file: 107 | json.dump(documents, file, indent=4) 108 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | openai 3 | pydantic 4 | python-multipart 5 | python-dotenv 6 | httpx 7 | py-cord[voice] 8 | twitter-api-client 9 | elevenlabs -------------------------------------------------------------------------------- /resources/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elizaOS/agentcomms/b037497170cc3b7cab6e20ffac842864ab8a4e61/resources/image.jpg -------------------------------------------------------------------------------- /resources/youcreatethefuture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elizaOS/agentcomms/b037497170cc3b7cab6e20ffac842864ab8a4e61/resources/youcreatethefuture.jpg -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # read the contexts of requirements.txt into an array 4 | required = [ 5 | "fastapi", 6 | "openai", 7 | "pydantic", 8 | "python-multipart", 9 | "python-dotenv", 10 | "httpx", 11 | "py-cord[voice]", 12 | "twitter-api-client", 13 | "elevenlabs", 14 | ] 15 | 16 | long_description = "" 17 | with open("README.md", "r") as fh: 18 | long_description = fh.read() 19 | # search for any lines that contain